Merge pull request #487 from fsolla/B178-salvador-cidade-agregada-no-campanha-pagina-virtual-lin

feat(B178): Salvador como cidade no /campanha — página virtual, linha comum na lista e busca, sem dupla contagem
This commit is contained in:
Francisco Solla 2026-08-09 10:21:16 -03:00 committed by GitHub
commit f1a8332835
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1648 additions and 115 deletions

View file

@ -0,0 +1,105 @@
# Impl: B178 — Salvador: cidade agregada no /campanha (página virtual + linha na lista + busca, sem dupla contagem)
Status: aprovado (gate humano 2026-08-09 — regra de assessor conforme recomendado: linha visível em qualquer recorte para coordinator/candidate; para advisor só quando `q` casa com Salvador; página da cidade `noLeader`)
Atualizado em: 2026-08-09
Issue: #461
Intenção: docs/plans/salvador-pagina-detalhe-cidade.md
Appetite restante: ~2–3 dias eng (herdado); agregação virtual, sem migration / collection / Consent
## Leitura da intenção
- **Outcome:** `/campanha/municipios/salvador` resolve para uma página read-only da cidade (rollup eleitoral + demandas/agenda + 19 ZEs); a lista de municípios mostra uma linha agregada da capital quando "Salvador" está no recorte (busca → agregado + 19 ZEs); o guardrail de dupla contagem é estrutural e testável.
- **O que NÃO negociar:** operação continua por ZE (cidade nunca vira unidade operacional / 436º município; sem pledges, lideranças, nível, demandas _por cidade_); cidade **ou** zonas em qualquer somatória — nunca ambas; cada ZE mantém ficha/linha; mapa intocado; leader lockdown (mesmo gate das páginas de município); sem migration/collection/Consent.
- **O que reavaliar:** a hipótese da intenção "loader da lista estendido para inserir a linha agregada" — a linha entra no **bundle** mas **fora do array de docs** (linha compacta acima da tabela, não dentro da tabela de 11 colunas com controles interativos — ver decisões abaixo); "reuso dos loaders de baseline por zona, somados" — o rollup do overview da página usa o **artefato commitado** (soma das chaves de zona), precedência do mapa/dashboard; o loader DB (`loadMunicipalityElectoralBaseline`) fica para a aba Eleições, que já soma por geografia.
## Abordagem recomendada
```mermaid
flowchart LR
A[catálogo 435 intocado] --> B[lib/salvadorCity.ts — descritor virtual + agregação do artefato]
B --> C[municipalityCityRow — predicado de recorte espelhando buildMunicipalityListWhere + doc sintético]
C --> D[lista: linha comum na tabela + mobile, merge no caminho in-memory, células read-only]
B --> E[página [slug]: branch isCitySlug → CityPage]
E --> F[ElectionsTab reusado via geography da cidade]
E --> G[Overview: rollup artefato + top ZEs + entradas]
B --> H[Sollinha: searchEntities + campaignNavigationUrls aceitam a cidade]
```
**A cidade vira uma LINHA COMUM da lista** (decisão do humano no gate 2026-08-09): participa de filtros, ordenação e paginação como uma entidade de município normal — não apenas no `q=`. O documento é **sintético** (nunca criado no banco): um objeto com forma de `Municipality` (slug `salvador`, name `Salvador (cidade)`, region, ibgeCode, `id` sentinela negativo, sem advisors/trend/level/expectedVotes/…), marcado com `isCity: true` no view model.
**Opções consideradas (cidade):** A = entrada `kind: 'cidade'` no `municipalityCatalog` · B = descritor virtual em módulo novo `src/lib/salvadorCity.ts` · C = estender `metropolitanoTerritoryPeers.ts`
**Recomendação:** B — o catálogo é a fonte das 435 unidades operacionais, congelada por snapshot (`municipality-catalog.snapshot.json`) e por dezenas de consumidores (mapa por map key, TI/E12, artefato eleitoral, `getStatewideFederalTotals`, testes de bijection); a cidade é leitura derivada e não pode entrar nele.
**Rejeitadas:** A porque quebra o snapshot, a contagem 435 e a semântica de `kind` (DB não tem `cidade`), e é exatamente a "aparência de 436º município" que produto vetou (a cidade é derivada, nunca seed); C porque `metropolitanoTerritoryPeers` é o módulo do split Salvador × demais RMS dentro do TI (E12/T4), escopo distinto da superfície da cidade.
**Opções consideradas (participação em filtros/sort/paginação):** A = linha só no `q=` (append pós-query) · B = linha comum com merge no caminho in-memory (recomendada) · C = linha comum com merge na página DB-paginada
**Recomendação:** B — quando o recorte seleciona a cidade, o bundle cai no caminho in-memory já existente (o mesmo do sort derivado/classe: `limit: 0` → filtro em memória → `applyDerivedMunicipalitySort` → slice). O caminho DB-paginado só é usado hoje com sort nativo (name/region/lastUpdateAt/trend) e sem classe; a cidade no recorte apenas estende a condição `isPagedByPayload` (nativeSort && !classMatches && !cityInRecorte). O custo extra é carregar ~435 selects de campos estreitos em vez de 25 — os reads pesados (scope, pledges, goal coverage) já rodam no caminho paginado.
**Rejeitadas:** C porque o merge na página SQL exige a posição ordinal da cidade na lista completa — não calculável com count/where sem carregar o conjunto ordenado inteiro; A porque é o que o humano vetou (só `q=`).
**Opções consideradas (células da linha):** A = células interativas recebem o id fantasma (popovers que falham no save) · B = flag `isCity` no view model + células read-only para a cidade (recomendada) · C = linha especial fora do `CampaignTable`
**Recomendação:** B — ~7 células interativas (expectedVotes, nível, assessores, tendência, lideranças, dobradinhas, sinal) mais os mobile cards renderizam o valor read-only (dash/badge "Não registrada"/classe real) quando `municipality.isCity`; nenhum popover abre para id inexistente; `MunicipalityListGoalCoverageCell` já renderiza o estado vazio (cobertura vazia) sem interação.
**Rejeitadas:** A porque o save falharia no servidor (município não existe) com UX quebrada; C porque contradiz "linha comum" e reabre a duplicação do row rendering.
**Opções consideradas (página):** A = branch dedicado no `[slug]/page.tsx` com `CityPage` reusando `ElectionsTab` · B = contexto sintético no fluxo do município (fabricar `Municipality` doc) · C = rota estática `salvador/page.tsx`
**Recomendação:** A — o shell (chrome + tab nav + Suspense) é o mesmo; a cidade tem abas com dono de dado (overview/elections/demands) e o resto do tab set (dossiê/lideranças/atualizações) simplesmente não existe para ela.
**Rejeitadas:** B porque o view model exigiria um `Municipality` falso (kind/advisorIDs mentem, dossiê/goal account/pledges quebrariam); C porque duplicaria o shell e divergiria do padrão de rota dinâmica.
**Opções consideradas (rollup eleitoral):** A = artefato commitado (`bahia-federal-baseline.json`, soma das 19 chaves de zona) · B = loader DB (`loadMunicipalityElectoralBaseline` com geography da cidade)
**Recomendação:** A para o card do overview (fonte abençoada dos agregados — "o mapa/dashboard nunca consultam essas collections para anos históricos"; puro, sem DB, sem cache) **+ B para a aba Eleições** (comparativo entre candidatos e dobradinhas dependem do DB; `municipalityElectionGeographyForSlug` passa a resolver a cidade, e o `ElectionsTab` reusa sem mudança — o loader já soma `cityCode × zones`).
**Rejeitadas:** usar só B no overview (4+ queries DB para uma superfície de leitura agregada, com cache `election-tse` que a cidade não precisa) e usar só A na aba Eleições (não há comparativo entre candidatos nem dobradinhas no artefato).
**Guardrail de dupla contagem (estrutural):** a cidade nunca entra em `municipalityCatalog`, nunca entra no artefato (o doc sintético só existe no bundle da lista e na página). Portanto nenhum consumidor de agregados (TI/E12, mapa B13, conta da cadeira, potencial, lista de territórios) pode somá-la; o sort/filtros in-memory usam a **soma derivada** (rank da cidade = soma das 19 chaves), nunca votos novos. Testes de invariante fixam isso (fase 1).
### Componentes / mudanças
- **`src/lib/salvadorCity.ts`** (novo, client-safe/puro): `SALVADOR_CITY_SLUG = 'salvador'`, descritor (name "Salvador (cidade)", region "Metropolitano de Salvador", `ibgeCode`/`tseCityCode` do catálogo, `zoneSlugs` derivados de `municipalityCatalogEntriesForCity('Salvador')`, `tseZones` [1..19]), `isCitySlug()`, `getSalvadorCityDescriptor()`, `cityFederalBaseline()` (soma por ano de `getMunicipalityFederalBaseline(zoneSlug)` para votes/valid/campo/tally + `getFederalCompetitiveRank(ibgeCode, ano)`), `cityVoteShareByYear()`, `cityVoteRankEntry(ano)` (votes = soma, share, rank/totalUnits competitivos — a "posição por cidade"). Reusa: catálogo (leitura), artefato, `computeAggregateTerritorialClass` para a classe da cidade.
- **`src/utilities/municipality/municipalityCityRow.ts`** (novo, server-safe): **`cityMatchesFilter(state)`** — predicado puro espelhando a semântica de `buildMunicipalityListWhere` sobre os valores virtuais da cidade: `q` (nome contém q), `regions` (Metropolitano de Salvador ∈), `slugs` ('salvador' ∈), `coverage: 'sem_assessor'` (sem assessores), classes (classe agregada real ∈), e os sentinelas de ausência (`sem_nivel`, `sem_dobradinha`, `sem_lideranca`, `sem_partido`); nunca casa com advisor/trend/level-nomeado/priority/estado-deputado/liderança/partido nomeados (a cidade não tem esses valores) — **+ `buildCityMunicipalityDoc()`** (doc sintético com forma de `Municipality`) **+ `cityListViewModel()`**.
- **`src/utilities/municipality/municipalityPageData.ts`**: (a) `isPagedByPayload` ganha `&& !cityInRecorte`; (b) no caminho in-memory, o doc sintético entra no array **antes** do `applyDerivedMunicipalitySort`; (c) `DerivedSortContext` ganha o rank da cidade (map mesclado para `votos`) e a classificação agregada (map de override para `classe`); (d) `totalDocs`/`totalPages` +1 quando a cidade está no recorte (paginação comum); (e) faceta `slugs` inclui 'salvador' quando no recorte; (f) `parseSlugsParam` aceita o slug da cidade **só na lista de municípios** (`isMunicipalitySlug || isCitySlug`).
- **`src/utilities/municipality/municipalityViewModels.ts`**: `MunicipalityListViewModel` ganha `isCity: boolean` (false no fluxo normal); `toMunicipalityListViewModel` recebe o flag.
- **`src/components/campaign/municipality/MunicipalityList.tsx`** + **`MunicipalityListMobileCards.tsx`**: células read-only para `municipality.isCity` (dash; tendência = badge "Não registrada"; classe = pill real; votos = readout da cidade; nome = link + badge "Cidade · agregado das 19 zonas"); nada de popover para id fantasma.
- **`src/utilities/municipality/municipalityOmnibox.ts`**: chips e seeds de slug resolvem o nome da cidade via descritor (client-safe) quando o slug é `salvador`.
- **`src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx`**: branch `isCitySlug(slug)` → `CityPage` (chrome "Salvador · Metropolitano de Salvador · ZE 1–19 · capital (agregado)", tab nav com `tabs` reduzidas, overview/elections/demands, `RecentVisitTracker`); `generateMetadata` com branch da cidade. Sem migration.
- **`src/components/campaign/municipality/MunicipalityTabNav.tsx`**: prop opcional `tabs` (default `municipalityDetailTabs`).
- **`src/utilities/municipality/municipalityElectionGeography.ts`**: `municipalityElectionGeographyForSlug` resolve a cidade (descritor) — `ElectionsTab` reusa inalterado.
- **`src/utilities/municipality/municipalityDetailTabUi.ts`**: `cityMunicipalityDetailTabs = ['overview','elections','demands']` (subset do enum existente, hrefs reaproveitados).
- **`src/components/campaign/municipality/CityOverview.tsx`** (novo, route-local): card de rollup (série 2014/2018/2022 + share + posição por cidade + evolução), top-5 ZEs por votos 2022 (artefato), entradas: 19 ZEs (→ `?q=salvador`), demandas (→ `/campanha/demandas`, padrão da aba do município), agenda (próximas atividades nas 19 ZEs — query `activity.municipality: { in: [ids das 19 ZEs] }` com access do user — + link Agenda), mapa (link).
- **`src/utilities/ai/tools/searchEntities.ts`**: hit `collection: 'cidade'` → `/campanha/municipios/salvador` quando a query casa com Salvador (aceite: "o link da Sollinha passa a ser canônico").
- **`src/utilities/ai/campaignNavigationUrls.ts`**: destino `'municipality'` aceita `isCitySlug` (mesma URL).
- **Migration:** nenhuma. **Access/Consent:** nenhum novo; a linha aparece para staff **unrestricted** (coordenador/candidato) em qualquer recorte que a selecione; para **assessor**, só quando `q` casa com Salvador (o portfólio do assessor é por ZE; a cidade não é administrada por ninguém — ver "Riscos"); página da cidade no gate `noLeader` (leitura); leitura eleitoral via `assertCanReadElectionData`; activities/demandas leem com `overrideAccess: false` + `user` (scoped por assessor).
### Dados → forma
- **Forma escolhida:** rollup da página = card de séries por ano (mesma forma da `MunicipalityBaselineCard`) com a **posição por cidade** ("12º de 663") em destaque no ano de referência; linha da lista = mesma coluna `votos` das demais (share + votos + posição), com a posição **competitiva** da cidade ("12º de 663" — os "mesmos números do rollup" do aceite).
- **Por quê:** a mesa lê relativo/local (share da própria votação + colocação entre candidatos na capital), nunca % estadual absoluto — precedência A11/E10. A posição por cidade é o competitivo do artefato (`federalRankByIbgeCode`, chave por IBGE — a capital inteira).
- **Rejeitadas:** posição por unidade do catálogo na linha (a cidade não é unidade — a semântica da coluna é "posição no catálogo de 435 unidades"); % dos válidos locais como âncora (mesmo veto do research report). Nuance de copy: o hint da coluna `votos` fala em "catálogo de unidades" — para a linha da cidade a leitura é competitiva (número correto via readout; hint genérico de coluna aceito, anotado para ajuste cosmético).
## Fases verificáveis
1. **Tracer / domínio puro** (sem UI): `lib/salvadorCity.ts` + `municipalityCityRow.ts` (`cityMatchesFilter` + doc sintético) + merge no bundle (in-memory, ranks/classes mesclados, totalDocs+1) + **testes de invariante** — (a) `cityFederalBaseline` == Σ das 19 chaves de zona por ano (a cidade é visão derivada das mesmas células, nunca votos novos); (b) `salvador` ausente de `federalBaselineMunicipalitySlugs()` e de `municipalityCatalog` (435 intactos, snapshot inalterado — nenhum consumidor de agregados pode dupla-contar); (c) `cityMatchesFilter` por dimensão (q, região, slug da cidade, sem_assessor, classe, sentinelas de ausência; nunca advisor/trend/nível nomeado/…); (d) bundle: `q=salvador` → 19 docs + cidade, totalDocs 20, paginação correta; `sort=name` → cidade na posição lexical; `sort=votos` → cidade no topo (desc). `pnpm gate:fast`.
2. **UI lista**: flag `isCity` no view model + células read-only (tabela + mobile cards) + badge "Cidade · agregado das 19 zonas" + omnibox/chips resolvendo o nome. `pnpm gate:fast`.
3. **UI página**: branch no `[slug]/page.tsx` + `MunicipalityTabNav.tabs` + `cityMunicipalityDetailTabs` + `municipalityElectionGeographyForSlug` resolvendo a cidade + `CityOverview` + aba demandas (link out) + metadata. `pnpm gate:fast`.
4. **Sollinha**: `searchEntities` + `campaignNavigationUrls`. Gates: `pnpm gate:fast` → `pnpm push` (PR `--base main` + `Closes #461` + auto-merge).
## Rabbit holes / Não escopo (engenharia)
- Operação por cidade (pledges, lideranças, nível, demandas por cidade, avaliação de assessor) — item sucessor, vetado no v1.
- Mudanças no mapa (polígono/legenda/interação) e no dossiê/TI/conta da cadeira — a cidade **não entra** nesses agregados (guardrail estrutural); nada a mudar neles.
- `municipalityPortfolio` (pickers de formulário) — continua listando as 19 ZEs (operação).
- Refatorar `CampaignTable` para suportar linha especial — desnecessário com a linha comum (células read-only).
- `getMunicipalityVotes`/`getMunicipalityOverview` (Sollinha) — continuam por ZE; a ferramenta de navegação é o suficiente para o aceite.
- `parseSlugsParam` de OUTRAS listas (`municipalityUpdateListUrl`) — a cidade só entra na lista de municípios; não estender `isMunicipalitySlug` globalmente.
## Riscos e mitigação
- **Linha comum com id sentinela vaza para controle interativo** → flag `isCity` + células read-only (nenhum popover para id inexistente); teste int garante que nenhum doc do bundle tem id fantasma fora do caminho da cidade.
- **Merge de paginação com sort nativo** → quando a cidade está no recorte, o caminho in-memory assume (condição `isPagedByPayload` estendida); o custo extra é ~410 selects de campos estreitos, e o default (deficit) já era in-memory. Medir no `pnpm build`/uso real; se pesar, revisitamos o merge na página SQL (defer com gatilho).
- **Filtros que a cidade "não tem"** (advisor/trend/nível nomeado/…) → `cityMatchesFilter` falha fechado (cidade fora do recorte), espelhando exatamente o que a entidade normal selecionaria com valores ausentes; sentinelas de ausência (`sem_nivel`, `sem_dobradinha`, `sem_lideranca`, `sem_partido`, `sem_assessor`) selecionam a cidade (ela não tem esses vínculos — semântica idêntica à de um município sem eles).
- **Assessor**: portfólio é por ZE; a cidade não é administrada por ninguém. Regra: a linha aparece para assessor **só quando `q` casa com Salvador** (busca explícita); coordenador/candidato (unrestricted) veem a linha em qualquer recorte que a selecione. Página da cidade permanece `noLeader` (leitura) para staff — sem vazamento (dados eleitorais staff-wide; activities/demandas scoped). **Confirmar com o humano.**
- **Ano sem posição** (`getFederalCompetitiveRank` null) → mostra a série sem fabricar "último lugar" (mesma regra do mapa).
- **Hint genérico da coluna `votos`** ("posição no catálogo de unidades") × posição competitiva da cidade → números corretos no readout; copy do hint aceita como está (anotado para polish futuro).
- **Facetas/omnibox**: slug `salvador` entra na faceta de slugs e nas seeds apenas quando no recorte; chips resolvem o nome via descritor client-safe (nunca mostram "slug cru").
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (página resolve; rollup + posição por cidade; linha comum na lista + busca agregado+zonas; guardrail testável; leader lockdown)
- [x] Invariantes AGENTS/engineering-standards (sem migration/collection/Consent; catálogo 435 e snapshot intocados; identificadores em inglês; copy pt-BR; `overrideAccess: false` com user)
- [x] Testes de domínio previstos: unit (`salvadorCity.unit.spec.ts`, `municipalityCityRow.unit.spec.ts`) + int (`municipalityPageData.int.spec.ts` — bundle com recortes: q=salvador, slug=salvador, região Metropolitano, sort=nome/votos, cidade fora com filtros que não a selecionam)

View file

@ -0,0 +1,292 @@
import Link from 'next/link'
import type { Payload } from 'payload'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import {
formatElectionNumber,
formatPlacementOrdinal,
formatVoteSharePercent,
} from '@/lib/electionFormat'
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { salvadorCity, salvadorZoneCatalogEntries } from '@/lib/salvadorCity'
import { computeVoteTrend } from '@/lib/voteTrend'
import type { Activity, CampaignUser } from '@/payload-types'
import { activityListSelect } from '@/utilities/activityViewModels'
import {
cityCompetitiveRank,
cityFederalBaseline,
cityVoteShareByYear,
} from '@/utilities/municipality/salvadorCityAggregates'
const CITY_SERIES_YEARS = [2014, 2018, ELECTION_YEAR_2022] as const
const now = () => new Date()
/**
* B178 — city overview for the virtual Salvador page: the electoral rollup
* folded from the committed artifact (the city never queries the TSE
* collections for its history), where the capital is strongest, and the
* entradas (19 ZEs, demandas, agenda, mapa). Read-only; operation stays per
* zone.
*/
export const CityOverviewTab = async ({
payload,
user,
}: {
payload: Payload
user: CampaignUser
}) => {
const baseline = cityFederalBaseline()
const series = CITY_SERIES_YEARS.map((year) => ({
year,
votes: baseline.votesByYear[String(year)] ?? 0,
share: cityVoteShareByYear(year),
competitive: cityCompetitiveRank(year),
}))
const evolution = computeVoteTrend({
y2014: series[0]!.votes,
y2018: series[1]!.votes,
y2022: series[2]!.votes,
})
const tally2022 = baseline.federalTallyByYear[String(ELECTION_YEAR_2022)]
const majoritarian = baseline.majoritarian2022
const topZones = salvadorZoneCatalogEntries()
.map((entry) => ({
entry,
votes:
getMunicipalityFederalBaseline(entry.slug).votesByYear[String(ELECTION_YEAR_2022)] ?? 0,
}))
.sort((left, right) => right.votes - left.votes)
.slice(0, 5)
const cityZoneIDs = (
await payload.find({
collection: 'municipality',
where: { slug: { in: salvadorCity.zoneSlugs } },
depth: 0,
limit: 0,
pagination: false,
user,
overrideAccess: false,
})
).docs.map((doc) => doc.id)
// Same upcoming shape as the Agenda tab ("confirmado" + not started), scoped
// to the city's zones and filtered by the actor's access.
const upcoming = cityZoneIDs.length
? ((
await payload.find({
collection: 'activity',
where: {
and: [
{ municipality: { in: cityZoneIDs } },
{ status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: now().toISOString() } },
],
},
depth: 0,
limit: 3,
pagination: false,
sort: 'startAt',
select: activityListSelect,
user,
overrideAccess: false,
})
).docs as Activity[])
: []
return (
<div className="flex flex-col gap-6">
<section
aria-labelledby="city-baseline-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="city-baseline-title" className="text-base font-medium">
Votação de {BASELINE_TICKET_2022.candidate.name} ({BASELINE_TICKET_2022.candidate.party}
)
</h2>
<p className="text-sm text-muted-foreground">
Soma das 19 zonas eleitorais · votos nominais para deputado federal (1º turno) — fonte
TSE.
</p>
</div>
<dl className="grid gap-3 sm:grid-cols-3">
{series.map(({ year, votes, share, competitive }) => (
<div
key={year}
className={
year === ELECTION_YEAR_2022
? 'rounded-lg bg-muted/60 px-3 py-2 ring-1 ring-border'
: 'rounded-lg bg-muted/40 px-3 py-2'
}
>
<dt className="text-xs font-medium text-muted-foreground">Eleição {year}</dt>
<dd className="text-lg font-medium tabular-nums">{formatElectionNumber(votes)}</dd>
<dd
className="mt-1 flex flex-col gap-0.5 text-xs tabular-nums text-muted-foreground"
aria-label={`${formatVoteSharePercent(share)} da votação estadual${competitive ? `, ${formatPlacementOrdinal(competitive.rank)} de ${formatElectionNumber(competitive.candidates)} na capital` : ''}`}
>
<span>{formatVoteSharePercent(share)}</span>
{competitive ? (
<span>
{formatPlacementOrdinal(competitive.rank)} de{' '}
{formatElectionNumber(competitive.candidates)}
</span>
) : (
<span>—</span>
)}
</dd>
</div>
))}
</dl>
{evolution.status !== 'noBaseline' ? (
<p className="text-sm text-muted-foreground">Evolução de votos: {evolution.message}</p>
) : null}
{tally2022 ? (
<div className="flex flex-col gap-1 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<p className="font-medium text-muted-foreground">Comparativos locais</p>
<p>
2022: {formatElectionNumber(tally2022.comparecimento)} compareceram ·{' '}
{formatElectionNumber(tally2022.votosValidos)} votos válidos
</p>
{tally2022.votosValidos > 0 ? (
<p>
Dominância local:{' '}
{formatVoteSharePercent(
(baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0) / tally2022.votosValidos,
)}{' '}
dos válidos na capital (distinto da concentração acima).
</p>
) : null}
</div>
) : null}
{majoritarian ? (
<p className="text-sm text-muted-foreground">
Chapa 2022: Lula {formatElectionNumber(majoritarian.president.votes)} votos · Jerônimo{' '}
{formatElectionNumber(majoritarian.governor.votes)} votos
</p>
) : null}
</section>
<section
aria-labelledby="city-zones-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="city-zones-title" className="text-base font-medium">
Onde na capital o candidato é mais forte ({ELECTION_YEAR_2022})
</h2>
<p className="text-sm text-muted-foreground">
A operação da campanha é por zona — esta é a leitura agregada, não uma unidade nova.
</p>
</div>
<ol className="flex flex-col">
{topZones.map(({ entry, votes }, index) => (
<li key={entry.slug} className="flex items-center justify-between gap-3 py-1.5">
<Link
href={`/campanha/municipios/${entry.slug}`}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
ZE {entry.zoneNumber}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(votes)}
</span>
<span aria-hidden="true" className="sr-only">
{index === 0 ? ' — maior votação da capital' : ''}
</span>
</li>
))}
</ol>
</section>
<section
aria-labelledby="city-entries-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="city-entries-title" className="text-base font-medium">
Entradas da capital
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">As 19 zonas</p>
<p className="text-sm text-muted-foreground">
Para operar, entre numa zona eleitoral — cada uma tem ficha, dossiê e linha próprias.
</p>
<Link
href="/campanha/municipios?q=salvador"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Ver as 19 zonas na lista
</Link>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Demandas</p>
<p className="text-sm text-muted-foreground">
As demandas são registradas por zona eleitoral.
</p>
<Link
href="/campanha/demandas"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Ver Demandas
</Link>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Agenda na capital</p>
{upcoming.length ? (
<ol className="flex flex-col gap-1">
{upcoming.map((activity) => (
<li key={activity.id} className="text-sm text-muted-foreground">
{activity.title}
</li>
))}
</ol>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma atividade confirmada nas zonas da capital.
</p>
)}
<Link
href="/campanha/atividades"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Ver Agenda
</Link>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Mapa</p>
<p className="text-sm text-muted-foreground">
No mapa, Salvador continua pintada por zona; a cidade segue como base não-interativa.
</p>
<Link
href="/campanha"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Ver mapa
</Link>
</div>
</div>
</section>
</div>
)
}
/** B178 — demands of the capital link out, same pattern as the municipality page. */
export const CityDemandsTab = () => (
<section className="rounded-xl border px-4 py-6">
<p className="text-sm text-muted-foreground">
As demandas da capital aparecem em{' '}
<Link href="/campanha/demandas" className="text-primary underline-offset-4 hover:underline">
Demandas
</Link>
. Elas são registradas por zona eleitoral — a capital é uma leitura agregada das 19 zonas.
</p>
</section>
)

View file

@ -9,8 +9,10 @@ import { MunicipalityTabNav } from '@/components/campaign/municipality/Municipal
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { isCitySlug, salvadorCity } from '@/lib/salvadorCity'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
cityMunicipalityDetailTabs,
resolveMunicipalityDetailTab,
type MunicipalityDetailSearchParams,
} from '@/utilities/municipality/municipalityDetailTabUi'
@ -22,6 +24,7 @@ import {
} from '@/utilities/municipality/municipalityPageData'
import { loadAdvisorSummaries } from '@/utilities/municipality/municipalityViewModels'
import { CityDemandsTab, CityOverviewTab } from './CityDetailSections'
import {
DossierTab,
ElectionsTab,
@ -31,8 +34,13 @@ import {
UpdatesTab,
} from './MunicipalityDetailTabs'
const CITY_PAGE_SUBTITLE = 'Metropolitano de Salvador · ZE 1–19 · capital (agregado das 19 zonas)'
export async function generateMetadata({ params }: MunicipalityDetailPageProps) {
const { slug } = await params
if (isCitySlug(slug)) {
return campaignPageMetadata({ title: salvadorCity.name, subtitle: CITY_PAGE_SUBTITLE })
}
const payload = await getPayload({ config })
const user = await requireCampaignPageActor({ gate: 'noLeader' })
@ -63,6 +71,44 @@ export default async function MunicipalityDetailPage({
getPayload({ config }),
])
// B178 — the virtual Salvador city has no DB document: the page is a
// read-only aggregate over the 19 zones, with its own reduced tab set.
if (isCitySlug(slug)) {
const resolvedTab = resolveMunicipalityDetailTab(rawSearchParams)
const cityTab = cityMunicipalityDetailTabs.includes(resolvedTab) ? resolvedTab : 'overview'
return (
<CampaignPageShell>
<SetCampaignPageChrome
chrome={{ title: salvadorCity.name, subtitle: CITY_PAGE_SUBTITLE }}
/>
<MunicipalityTabNav
activeTab={cityTab}
municipalitySlug={salvadorCity.slug}
searchParams={rawSearchParams}
tabs={cityMunicipalityDetailTabs}
/>
{cityTab === 'overview' ? <CityOverviewTab payload={payload} user={user} /> : null}
{cityTab === 'elections' ? (
<Suspense fallback={<MunicipalityTabFallback />}>
<ElectionsTab
slug={salvadorCity.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
</Suspense>
) : null}
{cityTab === 'demands' ? <CityDemandsTab /> : null}
<RecentVisitTracker
entry={{
href: `/campanha/municipios/${salvadorCity.slug}`,
label: salvadorCity.name,
kind: 'municipality',
}}
/>
</CampaignPageShell>
)
}
let context
try {
context = await resolveAccessibleMunicipalityContext(payload, user, slug)

View file

@ -35,6 +35,7 @@ import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { Badge } from '@/components/ui/Badge'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
@ -50,6 +51,7 @@ import {
type CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { SALVADOR_CITY_AGGREGATE_LABEL } from '@/lib/salvadorCity'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
@ -258,6 +260,28 @@ const municipalityListColumns = ({
</MunicipalityListUpdateControl>
)
// B178 — the city row is read-only: every interactive cell renders its
// value (or a dash) without a popover/overlay for a municipality that does
// not exist in the database. The trend badge mirrors the control's own
// null-status trigger ("Não registrada"), the class cell keeps its real
// aggregate class and the goal-coverage cell its honest empty state.
const cityDash = <span className="text-muted-foreground">—</span>
const renderTrendCell = (
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
) =>
municipality.isCity ? (
<Badge variant="outline">Não registrada</Badge>
) : (
renderTrendControl(municipality, triggerPresentation)
)
const renderSignalCell = (
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
) => (municipality.isCity ? cityDash : renderSignalControl(municipality, triggerPresentation))
return [
{
id: 'name',
@ -287,14 +311,22 @@ const municipalityListColumns = ({
>
{municipality.name}
</Link>
{municipality.priority === 'alta' && isStaffView ? (
{municipality.isCity ? (
<span className="mt-[5px] shrink-0">
<Badge variant="secondary">Cidade</Badge>
</span>
) : municipality.priority === 'alta' && isStaffView ? (
<span className="mt-[5px] shrink-0">
<MunicipalityPriorityIndicator />
</span>
) : null}
</div>
<span className="block truncate text-xs text-muted-foreground">
<TerritoryLink region={municipality.region} compact />
{municipality.isCity ? (
SALVADOR_CITY_AGGREGATE_LABEL
) : (
<TerritoryLink region={municipality.region} compact />
)}
</span>
</div>
),
@ -339,13 +371,17 @@ const municipalityListColumns = ({
cellClassName: 'relative overflow-visible align-middle text-center tabular-nums',
cell: (municipality) => (
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="popover"
/>
{municipality.isCity ? (
cityDash
) : (
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="popover"
/>
)}
</div>
),
},
@ -363,7 +399,9 @@ const municipalityListColumns = ({
/>
),
cell: (municipality) =>
canMoveEngagementLevel ? (
municipality.isCity ? (
cityDash
) : canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -424,7 +462,9 @@ const municipalityListColumns = ({
),
cellClassName: responsiveColumnClassName.advisors,
cell: (municipality) =>
isCoordinator ? (
municipality.isCity ? (
cityDash
) : isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -459,7 +499,7 @@ const municipalityListColumns = ({
description={municipalityColumnDescriptions.trend}
/>
),
cell: (municipality) => renderTrendControl(municipality, 'adaptive'),
cell: (municipality) => renderTrendCell(municipality, 'adaptive'),
},
{
id: 'leaderships',
@ -473,16 +513,19 @@ const municipalityListColumns = ({
</CampaignTableHead>
),
cellClassName: responsiveColumnClassName.leaderships,
cell: (municipality) => (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
/>
),
cell: (municipality) =>
municipality.isCity ? (
cityDash
) : (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
/>
),
},
{
// B176 — staff-wide since 2026-08-09: the edit is scoped to the
@ -502,17 +545,20 @@ const municipalityListColumns = ({
responsiveColumnClassName.stateDeputies,
'max-w-56 whitespace-normal',
),
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
cell: (municipality) =>
municipality.isCity ? (
cityDash
) : (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
{
@ -545,7 +591,7 @@ const municipalityListColumns = ({
{municipalityColumnLabels.lastSignal}
</MunicipalitySortableHead>
),
cell: (municipality) => renderSignalControl(municipality, 'adaptive'),
cell: (municipality) => renderSignalCell(municipality, 'adaptive'),
},
...(trendIsHidden || signalIsHidden
? ([
@ -561,8 +607,8 @@ const municipalityListColumns = ({
cellClassName: 'text-right',
cell: (municipality) => (
<div className="flex items-center justify-end gap-1">
{trendIsHidden ? renderTrendControl(municipality, 'compact') : null}
{signalIsHidden ? renderSignalControl(municipality, 'compact') : null}
{trendIsHidden ? renderTrendCell(municipality, 'compact') : null}
{signalIsHidden ? renderSignalCell(municipality, 'compact') : null}
</div>
),
},

View file

@ -26,6 +26,8 @@ import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { Badge } from '@/components/ui/Badge'
import { SALVADOR_CITY_AGGREGATE_LABEL } from '@/lib/salvadorCity'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
@ -95,6 +97,7 @@ export const MunicipalityListMobileCards = ({
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
const isCity = municipality.isCity
return (
<article
key={municipality.id}
@ -102,25 +105,32 @@ export const MunicipalityListMobileCards = ({
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">
<h3 className="flex items-center gap-1.5 font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
{isCity ? <Badge variant="secondary">Cidade</Badge> : null}
</h3>
<p className="text-sm text-muted-foreground">
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
{isCity ? (
SALVADOR_CITY_AGGREGATE_LABEL
) : (
<>
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</>
)}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{isPriority && isStaffView ? (
{!isCity && isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
@ -143,19 +153,25 @@ export const MunicipalityListMobileCards = ({
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
)}
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{canMoveEngagementLevel ? (
{isCity ? (
<span className="text-muted-foreground">—</span>
) : canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -176,35 +192,45 @@ export const MunicipalityListMobileCards = ({
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
{isCity ? (
<Badge variant="outline">Não registrada</Badge>
) : (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Última atualização</dt>
<dd>
<MunicipalityListUpdateControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListUpdateControl>
{isCity ? (
<SignalAgeReadout lastSignalAt={null} layout="card" />
) : (
<MunicipalityListUpdateControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListUpdateControl>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCoordinator ? (
{isCity ? (
<span className="text-muted-foreground">—</span>
) : isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -224,14 +250,18 @@ export const MunicipalityListMobileCards = ({
<div>
<dt className="text-muted-foreground">Lideranças</dt>
<dd>
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
/>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
/>
)}
</dd>
</div>
{/* B176 — staff-wide since 2026-08-09; the write stays scoped to
@ -240,15 +270,19 @@ export const MunicipalityListMobileCards = ({
<div className="col-span-2">
<dt className="text-muted-foreground">Dobradinhas</dt>
<dd>
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
/>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
/>
)}
</dd>
</div>
) : null}

View file

@ -21,10 +21,13 @@ export const MunicipalityTabNav = ({
activeTab,
municipalitySlug,
searchParams,
tabs = municipalityDetailTabs,
}: {
activeTab: MunicipalityDetailTab
municipalitySlug: string
searchParams: MunicipalityDetailSearchParams
/** B178 — the city page renders a reduced tab set (its data owners only). */
tabs?: readonly MunicipalityDetailTab[]
}) => (
<nav
aria-label="Seções do município"
@ -34,7 +37,7 @@ export const MunicipalityTabNav = ({
role="list"
className="m-0 flex h-full min-w-max list-none items-stretch gap-1 p-0 md:gap-0 lg:gap-1"
>
{municipalityDetailTabs.map((tab) => {
{tabs.map((tab) => {
const isActive = tab === activeTab
return (
<li key={tab} className="m-0 h-full p-0">

91
src/lib/salvadorCity.ts Normal file
View file

@ -0,0 +1,91 @@
/**
* B178 — Salvador as a derived city surface on top of the 19 zone
* municipalities (salvador-ze-1…19). Client-safe and pure: this module holds
* the descriptor and the label constants — it imports ONLY the municipality
* catalog (already shared with the client), never the election artifact.
*
* The artifact-folded aggregates (baseline, competitive rank, vote entries)
* live in `src/utilities/municipality/salvadorCityAggregates.ts` (server-only)
* so the 623 KB committed baseline never reaches the browser bundle.
*
* Guardrail de dupla contagem (structural): Salvador-as-city NEVER enters
* `municipalityCatalog` (still 435 operational units), never enters the
* election artifact, and never gets its own votes — `cityFederalBaseline` is a
* derived VIEW that sums the 19 zone keys, so every aggregate consumer that
* iterates the catalog or the artifact cannot double count it. The list bundle
* builds a synthetic row from this module; the city page reads it directly.
*/
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
getMunicipalityCatalogEntry,
municipalityCatalogEntriesForCity,
} from '@/lib/municipalityCatalog'
const SALVADOR_CITY = 'Salvador' as const
/** The virtual city's immutable URL segment — NOT a catalog slug. */
export const SALVADOR_CITY_SLUG = 'salvador' as const
/** List/detail display name of the aggregate row — "Salvador" is the city, the row is the aggregate. */
const SALVADOR_CITY_DISPLAY_NAME = 'Salvador (cidade)' as const
export const SALVADOR_CITY_AGGREGATE_LABEL = 'Cidade · agregado das 19 zonas' as const
export type SalvadorCityDescriptor = {
slug: typeof SALVADOR_CITY_SLUG
name: typeof SALVADOR_CITY_DISPLAY_NAME
city: typeof SALVADOR_CITY
region: BahiaIdentityTerritory
ibgeCode: string
tseCityCode: string
/** The 19 catalog zone slugs the city aggregates. */
zoneSlugs: readonly string[]
/** TSE zone numbers 1–19. */
tseZones: readonly number[]
}
const salvadorZoneEntries = (): ReturnType<typeof municipalityCatalogEntriesForCity> =>
municipalityCatalogEntriesForCity(SALVADOR_CITY)
const buildSalvadorCityDescriptor = (): SalvadorCityDescriptor => {
const zoneEntries = salvadorZoneEntries()
if (zoneEntries.length !== 19) {
// The catalog is the frozen 435 snapshot; Salvador's 19 ZEs are a product
// invariant of the remodel — fail loudly instead of aggregating a drift.
throw new Error(
`salvadorCity: expected 19 zone entries for Salvador, found ${zoneEntries.length}`,
)
}
const first = zoneEntries[0]!
return {
slug: SALVADOR_CITY_SLUG,
name: SALVADOR_CITY_DISPLAY_NAME,
city: SALVADOR_CITY,
region: first.region,
ibgeCode: first.ibgeCode,
tseCityCode: first.tseCityCode,
zoneSlugs: zoneEntries.map((entry) => entry.slug),
tseZones: zoneEntries.flatMap((entry) => entry.tseZones),
}
}
export const salvadorCity: SalvadorCityDescriptor = buildSalvadorCityDescriptor()
/** The only city slug today; a future city would extend this predicate. */
export const isCitySlug = (slug: string): boolean => slug === SALVADOR_CITY_SLUG
/**
* City-aware display name: the virtual city first, then the catalog. Used by
* client surfaces that label slugs (filter popovers, omnibox chips/seeds)
* where the city must never render as a raw slug.
*/
export const municipalityDisplayNameForSlug = (slug: string): string | null => {
if (isCitySlug(slug)) return salvadorCity.name
return getMunicipalityCatalogEntry(slug)?.name ?? null
}
/** The 19 ZE entries, ordered by zone number (catalog order). */
export const salvadorZoneCatalogEntries = (): ReturnType<
typeof municipalityCatalogEntriesForCity
> => salvadorZoneEntries()

View file

@ -15,6 +15,7 @@ import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import { isCitySlug } from '@/lib/salvadorCity'
import { activityStatuses } from '@/lib/schemas/activity'
import {
campaignDemandKinds,
@ -307,7 +308,9 @@ const buildPathForRequest = (
return { ok: true, path, label }
}
case 'municipality': {
if (!isMunicipalitySlug(request.slug)) {
// B178 — the virtual Salvador city is a canonical navigation destination
// (its page resolves); catalog slugs keep their contract.
if (!isMunicipalitySlug(request.slug) && !isCitySlug(request.slug)) {
return {
ok: false,
error: `Slug de município inválido: "${request.slug}". Resolva o município com searchEntities antes de montar o link.`,

View file

@ -3,6 +3,8 @@ import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { populatedContactName } from '@/lib/relationship'
import { salvadorCity } from '@/lib/salvadorCity'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
type SearchHit = {
collection: string
@ -27,6 +29,19 @@ export const searchEntities = (ctx: AIToolContext) =>
const hits: SearchHit[] = []
// B178 — the virtual Salvador city is a navigation destination the Sollinha
// keeps hallucinating about: whenever the query matches the capital, the
// city page is the canonical hit (the 19 ZE rows follow below).
const normalizedQuery = normalizeSearchPhrase(query)
if (normalizedQuery && normalizeSearchPhrase(salvadorCity.name).includes(normalizedQuery)) {
hits.push({
collection: 'cidade',
label: salvadorCity.name,
description: 'Cidade — agregado das 19 zonas eleitorais',
href: `/campanha/municipios/${salvadorCity.slug}`,
})
}
// Search municipalities
const munResult = await payload.find({
collection: 'municipality',

View file

@ -0,0 +1,143 @@
import 'server-only'
import { SALVADOR_CITY_SLUG, salvadorCity } from '@/lib/salvadorCity'
import type { Municipality } from '@/payload-types'
import { NO_PARTY_FILTER_VALUE, splitAbsenceFilterValues } from '@/utilities/campaignListUrl'
import {
NO_LEADERSHIP_FILTER_VALUE,
NO_LEVEL_FILTER_VALUE,
NO_STATE_DEPUTY_FILTER_VALUE,
type MunicipalityListSortDirection,
type MunicipalityListSortKey,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import {
computeAggregateTerritorialClass,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipality/municipalityTerritorialClass'
/**
* B178 — the Salvador city row of the municipality list: filter predicate,
* synthetic document and native-sort insertion. The city is a DERIVED surface
* (never a DB row — see `lib/salvadorCity.ts`), so every dimension of the
* frozen URL contract that WOULD select a normal municipality with the city's
* virtual values must select the city row too.
*/
/** Stable sentinel id of the synthetic city document — never a DB id. */
export const CITY_MUNICIPALITY_ID = -1 as const
/** Whether the free-text search matches the city's display name (mirrors the DB `name contains`). */
const cityMatchesQuery = (q: string): boolean => {
const needle = q.toLowerCase()
const name = salvadorCity.name.toLowerCase()
return name.includes(needle)
}
/**
* Mirrors `buildMunicipalityListWhere` semantics over the city's VIRTUAL
* values: the city has no advisors, trend, level, dobradinhas, leaderships or
* parties, so it only survives the dimensions that select absence, plus the
* ones where it carries real derived values (name, region, slug, class).
*/
export const cityMatchesFilter = (state: MunicipalityListState): boolean => {
if (state.q && !cityMatchesQuery(state.q)) return false
if (state.regions?.length && !state.regions.includes(salvadorCity.region)) return false
if (state.slugs?.length && !state.slugs.includes(SALVADOR_CITY_SLUG)) return false
if (state.advisors?.length) return false
if (state.coverage && state.coverage !== 'sem_assessor') return false
if (state.priority) return false
if (state.trends?.length) return false
if (state.classes?.length && !state.classes.includes(cityTerritorialClass().class)) return false
if (state.levels?.length) {
const { named, hasAbsence } = splitAbsenceFilterValues(
state.levels,
(level) => level === NO_LEVEL_FILTER_VALUE,
)
if (named.length || !hasAbsence) return false
}
if (state.stateDeputies?.length) {
const { named, hasAbsence } = splitAbsenceFilterValues(
state.stateDeputies,
(value) => value === NO_STATE_DEPUTY_FILTER_VALUE,
)
if (named.length || !hasAbsence) return false
}
if (state.leaderships?.length) {
const { named, hasAbsence } = splitAbsenceFilterValues(
state.leaderships,
(value) => value === NO_LEADERSHIP_FILTER_VALUE,
)
if (named.length || !hasAbsence) return false
}
if (state.parties?.length) {
const { named, hasAbsence } = splitAbsenceFilterValues(
state.parties,
(party) => party === NO_PARTY_FILTER_VALUE,
)
if (named.length || !hasAbsence) return false
}
return true
}
/**
* The class of the 19 zones read as one territory — the SAME classifier the
* TI rollup uses (`computeAggregateTerritorialClass`), never an average of
* per-zone classes: LQ is a ratio, and the ratio of the sums is the honest
* reading for the capital.
*/
export const cityTerritorialClass = (): MunicipalityTerritorialClassification =>
computeAggregateTerritorialClass(salvadorCity.zoneSlugs)
/**
* Synthetic `Municipality`-shaped document for the list machinery: fields the
* view model reads carry the city's virtual values, everything operational is
* absent (no advisors/trend/level/…), and the sentinel id guarantees no
* interactive control can ever target a real row. Never persisted.
*/
export const buildCityMunicipalityDoc = (): Municipality => ({
id: CITY_MUNICIPALITY_ID,
name: salvadorCity.name,
slug: salvadorCity.slug,
kind: 'municipio',
city: salvadorCity.city,
region: salvadorCity.region,
ibgeCode: salvadorCity.ibgeCode,
tseCityCode: salvadorCity.tseCityCode,
zoneNumber: null,
advisors: [],
stateDeputies: [],
priority: 'normal',
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
lastUpdateAt: null,
updatedAt: '',
createdAt: '',
})
/**
* Inserts the city row at its native-sort position WITHOUT re-sorting the rest
* of the list: the DB docs already carry the SQL order, and a full in-memory
* re-sort would drift collation (accents etc.). Only the city is compared —
* name/region by pt-BR collation; `lastUpdateAt`/`trend` have null city values
* and sort last in both directions (the app convention for nulls).
*/
export const insertCityAtNativeSortPosition = (
docs: readonly Municipality[],
city: Municipality,
sortKey: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): Municipality[] => {
if (sortKey !== 'name' && sortKey !== 'region') return [...docs, city]
const compareToCity = (doc: Municipality): number =>
sortKey === 'name'
? doc.name.localeCompare(city.name, 'pt-BR')
: doc.region.localeCompare(city.region, 'pt-BR')
const ascending = dir === 'asc'
const index = docs.findIndex((doc) =>
ascending ? compareToCity(doc) > 0 : compareToCity(doc) < 0,
)
if (index === -1) return [...docs, city]
return [...docs.slice(0, index), city, ...docs.slice(index)]
}

View file

@ -12,6 +12,18 @@ export const municipalityDetailTabs = [
export type MunicipalityDetailTab = (typeof municipalityDetailTabs)[number]
export type MunicipalityDetailSearchParams = DetailTabSearchParams
/**
* B178 — the virtual Salvador city has no operational data (no dossiê,
* lideranças or atualizações per city), so its tab set is the subset with a
* data owner for the city: overview (rollup + entradas), elections and
* demands. Subset of the same enum, so tab hrefs reuse the helpers below.
*/
export const cityMunicipalityDetailTabs: readonly MunicipalityDetailTab[] = [
'overview',
'elections',
'demands',
]
const helpers = createDetailTabHelpers<MunicipalityDetailTab>({
tabs: municipalityDetailTabs,
defaultTab: 'overview',

View file

@ -4,12 +4,17 @@ import {
getMunicipalityCatalogEntry,
type MunicipalityCatalogEntry,
} from '@/lib/municipalityCatalog'
import { isCitySlug, salvadorCity } from '@/lib/salvadorCity'
/**
* Election-data geography of a Município: exactly one municipality (TSE cityCode)
* and either all of its official zones (whole-municipality unit) or the single
* zone (zona unit). Election collections are keyed by cityCode × zoneNumber,
* so the cut at the municipal boundary is exact.
*
* B178 — the virtual Salvador city resolves to the WHOLE-CITY geography
* (`cityCode × zones 1–19`), the same shape a whole municipality uses, so the
* DB-backed elections tab reads the capital by summing its zones.
*/
export type MunicipalityElectionGeography = {
cityCode: string
@ -23,9 +28,15 @@ export const municipalityElectionGeography = (
zones: [...entry.tseZones],
})
const cityElectionGeography = (): MunicipalityElectionGeography => ({
cityCode: salvadorCity.tseCityCode,
zones: [...salvadorCity.tseZones],
})
export const municipalityElectionGeographyForSlug = (
slug: string,
): MunicipalityElectionGeography | null => {
if (isCitySlug(slug)) return cityElectionGeography()
const entry = getMunicipalityCatalogEntry(slug)
return entry ? municipalityElectionGeography(entry) : null
}

View file

@ -6,6 +6,7 @@
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { isCitySlug, municipalityDisplayNameForSlug } from '@/lib/salvadorCity'
import { NO_PARTY_FILTER_VALUE, truncatedNamesLabel } from '@/utilities/campaignListUrl'
import {
municipalityListCoverageLabels,
@ -47,11 +48,18 @@ export type MunicipalityFilterOption = {
label: string
}
/** Catalog labels for a (possibly cross-filtered) slug list. */
/**
* B178 — city-aware catalog labels for a (possibly cross-filtered) slug list:
* the virtual city slug resolves through the city descriptor so it never
* renders raw.
*/
export const municipalityFilterOptionsForSlugs = (
slugs: readonly string[],
): MunicipalityFilterOption[] =>
slugs.map((slug) => ({ value: slug, label: getMunicipalityCatalogEntry(slug)?.name ?? slug }))
slugs.map((slug) => ({
value: slug,
label: municipalityDisplayNameForSlug(slug) ?? slug,
}))
export type MunicipalityFilterDefinition = {
param: MunicipalityFilterParam
@ -287,7 +295,11 @@ export const buildMunicipalityFilterOptionHref = (
if (regions.length) next.regions = regions
else delete next.regions
} else if (param === 'slug') {
const slugs = toggled.filter((slug) => getMunicipalityCatalogEntry(slug) !== undefined)
// B178 — the city slug toggles like a catalog slug (the row is a common
// list entity), even though it is not a catalog unit.
const slugs = toggled.filter(
(slug) => getMunicipalityCatalogEntry(slug) !== undefined || isCitySlug(slug),
)
if (slugs.length) next.slugs = slugs
else delete next.slugs
} else if (param === 'advisor') {
@ -360,9 +372,7 @@ export const formatMunicipalityActiveFiltersSummary = (
if (state.priority) parts.push(municipalityPriorityLabels.alta)
if (state.slugs?.length) {
parts.push(
truncatedNamesLabel(
state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug),
),
truncatedNamesLabel(state.slugs.map((slug) => municipalityDisplayNameForSlug(slug) ?? slug)),
)
}
if (state.regions?.length) parts.push(truncatedNamesLabel([...state.regions]))

View file

@ -9,6 +9,7 @@ import type { Where } from 'payload'
import { type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { engagementLevels, type EngagementLevel } from '@/lib/engagementLevel'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import { isCitySlug } from '@/lib/salvadorCity'
import {
allParamValues,
buildListHref,
@ -303,11 +304,19 @@ export const formatMunicipalityListSortSummary = (
const parseRegionsParam = parseTerritoryRegionsParam
/**
* B178 — the municipality list also accepts the virtual city slug (`salvador`):
* the city row participates in the slug filter like a normal entity. Deliberately
* list-scoped: `isMunicipalitySlug` (the global catalog) stays untouched.
*/
const isMunicipalityOrCitySlug = (value: string): boolean =>
isMunicipalitySlug(value) || isCitySlug(value)
const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
const slugs: string[] = []
const seen = new Set<string>()
for (const token of allParamValues(raw)) {
if (!isMunicipalitySlug(token) || seen.has(token)) continue
if (!isMunicipalityOrCitySlug(token) || seen.has(token)) continue
seen.add(token)
slugs.push(token)
}

View file

@ -7,7 +7,7 @@ import {
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { municipalityDisplayNameForSlug } from '@/lib/salvadorCity'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
@ -111,7 +111,7 @@ export const buildMunicipalityOmniboxChips = ({
for (const slug of state.slugs ?? []) {
chips.push({
id: `slug:${slug}`,
label: chipLabel('Município', getMunicipalityCatalogEntry(slug)?.name ?? slug),
label: chipLabel('Município', municipalityDisplayNameForSlug(slug) ?? slug),
})
}

View file

@ -12,9 +12,14 @@ import {
type MunicipalityVoteRankEntry,
} from '@/lib/municipalityVoteRank'
import { relationshipId } from '@/lib/relationship'
import { SALVADOR_CITY_SLUG } from '@/lib/salvadorCity'
import { type VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import {
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import {
@ -25,6 +30,12 @@ import {
centralDeficitSortValue,
type MunicipalityGoalCoverage,
} from '@/utilities/municipality/goalCoverage'
import {
buildCityMunicipalityDoc,
cityMatchesFilter,
cityTerritorialClass,
insertCityAtNativeSortPosition,
} from '@/utilities/municipality/municipalityCityRow'
import {
loadMunicipalityGoalCoverageBundle,
loadStatewideSuggestedGoals,
@ -45,6 +56,7 @@ import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/munici
import {
computeMunicipalityTerritorialClass,
territorialClassSortWeight,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipality/municipalityTerritorialClass'
import {
loadMunicipalityLeadershipSummaries,
@ -55,6 +67,10 @@ import {
type MunicipalityLeadershipSummary,
type MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import {
citySortVoteEntry,
cityVoteRankEntry,
} from '@/utilities/municipality/salvadorCityAggregates'
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
import { type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
@ -302,13 +318,23 @@ type DerivedSortContext = {
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
>
pledgeAggregates: ReadonlyMap<number, MunicipalityPledgeAggregate>
/** B178 — city aggregate class override (the artifact lookup by slug would miss). */
classBySlug?: ReadonlyMap<string, MunicipalityTerritorialClassification>
/** B178 — the virtual city row: inserted at its native-sort position. */
citySlug?: string
}
const applyDerivedMunicipalitySort = (
docs: Municipality[],
sortKey: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
{ ranks, goalCoverageByMunicipalityID, pledgeAggregates }: DerivedSortContext,
{
ranks,
goalCoverageByMunicipalityID,
pledgeAggregates,
classBySlug,
citySlug,
}: DerivedSortContext,
): Municipality[] => {
switch (sortKey) {
case 'expectedVotes':
@ -363,12 +389,27 @@ const applyDerivedMunicipalitySort = (
docs,
dir,
(municipality) =>
territorialClassSortWeight[computeMunicipalityTerritorialClass(municipality.slug).class],
territorialClassSortWeight[
(
classBySlug?.get(municipality.slug) ??
computeMunicipalityTerritorialClass(municipality.slug)
).class
],
)
default:
// Native keys reach here when a derived FILTER forced the in-memory
// path; the query already sorted them, so the order is preserved.
return docs
// path; the query already sorted them, so the order is preserved — with
// one exception: the virtual city row never passed through SQL, so it is
// inserted at its own position without re-sorting the DB docs.
if (!citySlug) return docs
const city = docs.find((doc) => doc.slug === citySlug)
if (!city) return docs
return insertCityAtNativeSortPosition(
docs.filter((doc) => doc !== city),
city,
sortKey,
dir,
)
}
}
@ -379,6 +420,7 @@ export const loadMunicipalityListPageBundle = async (
): Promise<MunicipalityListPageBundle> => {
const state = parseMunicipalityListParams(searchParams)
const isStaff = isCampaignStaff(user)
const isUnrestricted = isCampaignUnrestricted(user)
const { sort: sortKey, dir: sortDir } = resolveMunicipalityListSort(state)
if (isCampaignLeader(user)) {
@ -401,11 +443,36 @@ export const loadMunicipalityListPageBundle = async (
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const nativeSortField = payloadSortFieldByKey[sortKey]
const classMatches = territorialClassFilterPredicate(state)
// B178 — the Salvador city row behaves like a normal entity: it is selected
// by the same filters that would select a municipality with its virtual
// values. Advisors only see it when they explicitly search for the city
// (their portfolio is per-ZE and the city is not administered by anyone);
// coordinator/candidate see it in any recorte that selects it.
const cityInRecorte = cityMatchesFilter(state) && (isUnrestricted || Boolean(state.q))
// A derived filter can't be expressed in `where`, so it forces the
// load-everything path even when the SORT itself is native — otherwise the
// page would drop rows out of a 25-row window and `totalDocs` would count
// municípios the filter excludes.
const isPagedByPayload = nativeSortField !== undefined && !classMatches
// municípios the filter excludes. The virtual city row can never ride the
// SQL page either: it only exists in memory.
const isPagedByPayload = nativeSortField !== undefined && !classMatches && !cityInRecorte
// B178 — two rank views of the same 435-unit table: the sort ranks carry the
// city's summed votes (so `votos` orders it by its real total), the row
// ranks carry the city's COMPETITIVE entry ("12º de 663") when the year has
// one — without it the column renders its dash instead of a fake rank.
let sortRanks: ReadonlyMap<string, MunicipalityVoteRankEntry> = ranks
let rowRanks: ReadonlyMap<string, MunicipalityVoteRankEntry> = ranks
if (cityInRecorte) {
const mergedSort = new Map(ranks)
mergedSort.set(SALVADOR_CITY_SLUG, citySortVoteEntry())
sortRanks = mergedSort
const mergedRows = new Map(ranks)
const cityEntry = cityVoteRankEntry()
if (cityEntry) mergedRows.set(SALVADOR_CITY_SLUG, cityEntry)
rowRanks = mergedRows
}
const classBySlug: ReadonlyMap<string, MunicipalityTerritorialClassification> | undefined =
cityInRecorte ? new Map([[SALVADOR_CITY_SLUG, cityTerritorialClass()]]) : undefined
const listQuery = payload.find({
collection: 'municipality',
@ -469,6 +536,12 @@ export const loadMunicipalityListPageBundle = async (
isStaff ? loadStatewideSuggestedGoals(payload, user) : null,
])
// B178 — the city slug joins the name-filter options whenever the recorte
// shows the city row, so the filter can be applied/undone like any other.
if (cityInRecorte && !filterFacets.slugs.includes(SALVADOR_CITY_SLUG)) {
filterFacets.slugs = [...filterFacets.slugs, SALVADOR_CITY_SLUG]
}
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
let goalCoverageByMunicipalityID = new Map<
number,
@ -504,11 +577,24 @@ export const loadMunicipalityListPageBundle = async (
classMatches(municipality.slug),
)
: (listResult.docs as Municipality[])
const allDocs = applyDerivedMunicipalitySort(scopedDocs, sortKey, sortDir, {
ranks,
goalCoverageByMunicipalityID,
pledgeAggregates,
})
// B178 — the city row joins the in-memory recorte (class filter applied to
// its AGGREGATE class, not the per-slug artifact lookup). It enters the
// sort with its virtual values; native sorts position it explicitly.
const cityClassSelected =
!classMatches || Boolean(state.classes?.includes(cityTerritorialClass().class))
const cityDoc = cityInRecorte && cityClassSelected ? buildCityMunicipalityDoc() : null
const allDocs = applyDerivedMunicipalitySort(
cityDoc ? [...scopedDocs, cityDoc] : scopedDocs,
sortKey,
sortDir,
{
ranks: sortRanks,
goalCoverageByMunicipalityID,
pledgeAggregates,
classBySlug,
citySlug: cityDoc ? SALVADOR_CITY_SLUG : undefined,
},
)
totalDocs = allDocs.length
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
const start = (state.page - 1) * municipalityPageSize
@ -527,15 +613,18 @@ export const loadMunicipalityListPageBundle = async (
: null
return {
municipalities: pageDocs.map((municipality) =>
toMunicipalityListViewModel(
municipalities: pageDocs.map((municipality) => {
const isCity = municipality.slug === SALVADOR_CITY_SLUG
return toMunicipalityListViewModel(
municipality,
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
ranks.get(municipality.slug) ?? null,
rowRanks.get(municipality.slug) ?? null,
isStaff ? goalCoverageByMunicipalityID.get(municipality.id) : undefined,
leadershipBundle?.leadershipIDsByMunicipality.get(municipality.id) ?? [],
),
),
isCity,
isCity ? classBySlug?.get(SALVADOR_CITY_SLUG) : undefined,
)
}),
totalDocs,
totalPages,
scopeTotal: scopeCount.totalDocs,

View file

@ -21,6 +21,7 @@ import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/munici
import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
type MunicipalityTerritorialClassification,
type TerritorialFactor,
} from '@/utilities/municipality/municipalityTerritorialClass'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
@ -65,6 +66,8 @@ export type MunicipalityListViewModel = {
region: string
ibgeCode: string
zoneNumber: number | null
/** B178 — the Salvador city aggregate row (virtual, read-only; never a DB row). */
isCity: boolean
advisorIDs: number[]
/** B155 — leadership ids linked to this município (reverse read of `leadership.municipalities`). */
leadershipIDs: number[]
@ -102,8 +105,13 @@ export const toMunicipalityListViewModel = (
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
/** B155 — empty for non-staff views; the leader never renders this column. */
leadershipIDs: number[] = [],
/** B178 — the city row carries no operational data and renders read-only cells. */
isCity = false,
/** B178 — override for the city's AGGREGATE class (the artifact lookup by slug would miss). */
territorialClassOverride?: MunicipalityTerritorialClassification,
): MunicipalityListViewModel => {
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug)
const territorialClass =
territorialClassOverride ?? computeMunicipalityTerritorialClass(municipality.slug)
return {
id: municipality.id,
@ -114,6 +122,7 @@ export const toMunicipalityListViewModel = (
region: municipality.region,
ibgeCode: municipality.ibgeCode,
zoneNumber: municipality.zoneNumber ?? null,
isCity,
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),

View file

@ -0,0 +1,154 @@
import 'server-only'
import {
getFederalCompetitiveRank,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type FederalCompetitiveRank,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank'
import { salvadorCity } from '@/lib/salvadorCity'
/**
* B178 — Salvador city aggregates folded from the COMMITTED election artifact
* (the 623 KB `bahia-federal-baseline.json`). Server-only on purpose: this
* module must never reach the browser bundle — the list row renders the city
* numbers from the view model, and the client only imports the cheap
* descriptor/labels from `lib/salvadorCity.ts`.
*
* The city never carries its own votes: every number here is a derived VIEW
* over the 19 zone-slug keys, so the guardrail de dupla contagem holds by
* construction (the artifact has no `salvador` key; `getStatewideFederalTotals`
* iterates artifact keys only).
*/
/**
* The city baseline = the SUM of its 19 zone-slug baselines, folded once per
* process (the artifact is immutable).
*/
export const cityFederalBaseline = (): MunicipalityFederalBaseline => {
const total: MunicipalityFederalBaseline = {
votesByYear: {},
validVotesByYear: {},
campoFederalVotesByYear: {},
federalTallyByYear: {},
}
let majoritarian: MunicipalityFederalBaseline['majoritarian2022'] | undefined
for (const slug of salvadorCity.zoneSlugs) {
const zone = getMunicipalityFederalBaseline(slug)
for (const [yearKey, votes] of Object.entries(zone.votesByYear)) {
total.votesByYear[yearKey] = (total.votesByYear[yearKey] ?? 0) + votes
}
for (const [yearKey, votes] of Object.entries(zone.validVotesByYear)) {
total.validVotesByYear[yearKey] = (total.validVotesByYear[yearKey] ?? 0) + votes
}
for (const [yearKey, votes] of Object.entries(zone.campoFederalVotesByYear)) {
total.campoFederalVotesByYear[yearKey] = (total.campoFederalVotesByYear[yearKey] ?? 0) + votes
}
for (const [yearKey, tally] of Object.entries(zone.federalTallyByYear)) {
const accumulated = total.federalTallyByYear[yearKey] ?? {
comparecimento: 0,
votosValidos: 0,
votosBranco: 0,
votosNulo: 0,
}
total.federalTallyByYear[yearKey] = {
comparecimento: accumulated.comparecimento + tally.comparecimento,
votosValidos: accumulated.votosValidos + tally.votosValidos,
votosBranco: accumulated.votosBranco + tally.votosBranco,
votosNulo: accumulated.votosNulo + tally.votosNulo,
}
}
if (zone.majoritarian2022) {
const sum = (
current: {
votes: number
comparecimento: number
votosValidos: number
votosBranco: number
votosNulo: number
},
cell: {
votes: number
comparecimento: number
votosValidos: number
votosBranco: number
votosNulo: number
},
) => ({
votes: current.votes + cell.votes,
comparecimento: current.comparecimento + cell.comparecimento,
votosValidos: current.votosValidos + cell.votosValidos,
votosBranco: current.votosBranco + cell.votosBranco,
votosNulo: current.votosNulo + cell.votosNulo,
})
if (!majoritarian) {
majoritarian = {
president: sum(
{ votes: 0, comparecimento: 0, votosValidos: 0, votosBranco: 0, votosNulo: 0 },
zone.majoritarian2022.president,
),
governor: sum(
{ votes: 0, comparecimento: 0, votosValidos: 0, votosBranco: 0, votosNulo: 0 },
zone.majoritarian2022.governor,
),
}
} else {
majoritarian.president = sum(majoritarian.president, zone.majoritarian2022.president)
majoritarian.governor = sum(majoritarian.governor, zone.majoritarian2022.governor)
}
}
}
if (majoritarian) total.majoritarian2022 = majoritarian
return total
}
/** Share of the candidate's statewide (artifact) votes that came from Salvador, per year — 0..1. */
export const cityVoteShareByYear = (year: number): number => {
const ownVotes = getStatewideFederalTotals(year).ownVotes
if (ownVotes <= 0) return 0
return (cityFederalBaseline().votesByYear[String(year)] ?? 0) / ownVotes
}
/**
* Competitive placement of the candidate INSIDE the capital for one year
* ("12º de 663" — the per-city rank the artifact keys by IBGE code). `null`
* when he took no votes there that year — never a fabricated last place.
*/
export const cityCompetitiveRank = (year: number): FederalCompetitiveRank | null =>
getFederalCompetitiveRank(salvadorCity.ibgeCode, year)
/**
* The list column's readout entry for the city: votes + share of own vote +
* the city's COMPETITIVE position ("mesmos números do rollup" of the page).
* `null` when the year has no competitive rank (renders the column dash).
*/
export const cityVoteRankEntry = (
year: number = DEFAULT_VOTE_RANK_YEAR,
): MunicipalityVoteRankEntry | null => {
const competitive = cityCompetitiveRank(year)
if (!competitive) return null
return {
votes: cityFederalBaseline().votesByYear[String(year)] ?? 0,
share: cityVoteShareByYear(year),
rank: competitive.rank,
totalUnits: competitive.candidates,
}
}
/**
* Vote entry used ONLY for ordering (`votos` sort): the city sorts by its
* real summed votes even when a year lacks a competitive rank. Never rendered.
*/
export const citySortVoteEntry = (
year: number = DEFAULT_VOTE_RANK_YEAR,
): MunicipalityVoteRankEntry => ({
votes: cityFederalBaseline().votesByYear[String(year)] ?? 0,
share: cityVoteShareByYear(year),
rank: 0,
totalUnits: 0,
})

View file

@ -459,4 +459,166 @@ describe('loadMunicipalityListPageBundle', () => {
expect(bundle.municipalities).toHaveLength(0)
expect(bundle.scopeTotal).toBe(0)
})
// -------------------------------------------------------------------------
// B178 — Salvador city aggregate row (virtual, common-row behavior)
// -------------------------------------------------------------------------
const salvadorZoneSlugs = municipalityCatalog
.filter((entry) => entry.kind === 'zona' && entry.city === 'Salvador')
.map((entry) => entry.slug)
const cityRow = (bundle: Awaited<ReturnType<typeof loadMunicipalityListPageBundle>>) =>
bundle.municipalities.find((row) => row.slug === 'salvador')
it('shows the city row alongside the 19 zones when q matches Salvador', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'salvador' })
expect(bundle.municipalities).toHaveLength(20)
expect(bundle.totalDocs).toBe(20)
for (const slug of salvadorZoneSlugs) {
expect(bundle.municipalities.some((row) => row.slug === slug)).toBe(true)
}
const city = cityRow(bundle)
expect(city).toBeDefined()
expect(city?.isCity).toBe(true)
expect(city?.id).toBeLessThan(0)
})
it('includes the city row in the default recorte with honest totals (436 rows)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {})
// The city counts as a row (it is a common line of the list) but never
// as an operational unit: the catalog stays at 435.
expect(bundle.totalDocs).toBe(municipalityCatalog.length + 1)
expect(bundle.totalPages).toBe(Math.ceil(bundle.totalDocs / 25))
expect(bundle.municipalities).toHaveLength(25)
})
it('renders the city row with the competitive position and a real aggregate class', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'salvador' })
const city = cityRow(bundle)
expect(city?.votePosition2022).not.toBeNull()
expect(city?.votePosition2022?.votes).toBeGreaterThan(0)
expect(city?.votePosition2022?.rank).toBeGreaterThanOrEqual(1)
expect(city?.votePosition2022?.totalUnits).toBeGreaterThanOrEqual(city!.votePosition2022!.rank)
// The aggregate class of the capital comes from the summed artifact, never
// from a per-slug lookup that would read "Sem base" for the unknown slug.
expect(city?.territorialClass).not.toBe('sem_base')
})
it('selects the city row by its own slug filter', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
slug: ['salvador'],
})
expect(bundle.municipalities).toHaveLength(1)
expect(cityRow(bundle)?.isCity).toBe(true)
expect(bundle.totalDocs).toBe(1)
})
it('selects the city row under the Metropolitano region filter but not others', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const metropolitano = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
region: ['Metropolitano de Salvador'],
})
expect(cityRow(metropolitano)).toBeDefined()
expect(metropolitano.totalDocs).toBe(20)
const chapada = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
region: ['Chapada Diamantina'],
})
expect(cityRow(chapada)).toBeUndefined()
expect(chapada.municipalities).toHaveLength(0)
})
it('selects the city row under absence sentinels and excludes it under named values', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const semNivel = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
level: ['sem_nivel'],
})
expect(cityRow(semNivel)).toBeDefined()
expect(semNivel.totalDocs).toBe(20)
const nivelN0 = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
level: ['n0'],
})
expect(cityRow(nivelN0)).toBeUndefined()
expect(nivelN0.municipalities).toHaveLength(0)
const comAssessor = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
coverage: 'com_assessor',
})
expect(cityRow(comAssessor)).toBeUndefined()
})
it('positions the city row under a native sort without re-sorting the rest', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
q: 'salvador',
sort: 'name',
})
// The DB rows keep the SQL order verbatim — the city is INSERTED, never
// re-sorted with the rest (a full in-memory sort would drift collation).
const direct = await payload.find({
collection: 'municipality',
where: { name: { contains: 'salvador' } },
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { slug: true },
overrideAccess: true,
})
const directOrder = direct.docs.map((doc) => doc.slug)
const bundleZoneOrder = bundle.municipalities
.filter((row) => row.slug !== 'salvador')
.map((row) => row.slug)
expect(bundleZoneOrder).toEqual(directOrder)
// The city lands inside the Salvador block — under pt-BR collation the
// "— ZE N" names sort before "Salvador (cidade)", so it sits at the block
// edge (immediately after the last zone), never at the recorte start.
const cityIndex = bundle.municipalities.findIndex((row) => row.slug === 'salvador')
expect(cityIndex).toBeGreaterThan(0)
expect(bundle.municipalities[cityIndex - 1]?.kind).toBe('zona')
})
it('shows the city row to an advisor only when they search for the city', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const defaultList = await loadMunicipalityListPageBundle(payload, advisor, {})
expect(cityRow(defaultList)).toBeUndefined()
const searched = await loadMunicipalityListPageBundle(payload, advisor, { q: 'salvador' })
expect(cityRow(searched)).toBeDefined()
})
})

View file

@ -214,6 +214,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
isCity: false,
advisorIDs: [advisor.id],
leadershipIDs: [11],
stateDeputyIDs: [],
@ -259,6 +260,7 @@ describe('campaign visual foundation', () => {
region: 'Metropolitano de Salvador',
ibgeCode: '2927408',
zoneNumber: 3,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
@ -348,6 +350,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
isCity: false,
priority: 'normal',
advisorIDs: [],
leadershipIDs: [],
@ -406,6 +409,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
isCity: false,
priority: 'normal',
advisorIDs: [],
leadershipIDs: [],
@ -537,6 +541,7 @@ describe('campaign visual foundation', () => {
region: 'Bacia do Rio Grande',
ibgeCode: '2903201',
zoneNumber: null,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
@ -583,6 +588,7 @@ describe('campaign visual foundation', () => {
region: 'Semiárido Nordeste II',
ibgeCode: '2932002',
zoneNumber: null,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
@ -626,6 +632,7 @@ describe('campaign visual foundation', () => {
region: 'Centro Norte',
ibgeCode: '2914703',
zoneNumber: null,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
@ -675,6 +682,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
@ -855,6 +863,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
isCity: false,
advisorIDs: [],
leadershipIDs: [],
priority: 'normal' as const,

View file

@ -48,6 +48,14 @@ describe('campaignNavigationUrls', () => {
}
})
it('accepts the virtual Salvador city slug (B178)', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipality',
slug: 'salvador',
})
expect(outcome).toEqual({ ok: true, path: '/campanha/municipios/salvador', label: 'Município' })
})
it('delegates municipality list filters to buildMunicipalityListHref', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipalityList',

View file

@ -553,6 +553,10 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
'src/utilities/municipality/municipalityViewModels.ts',
'src/utilities/visit/visitPlannerData.ts',
'src/utilities/votePledgeData.ts',
// B178 — the city overview batches activities across the 19 Salvador zone
// ids (a domain filter over a known set, not an actor access scope); the
// single-id form the dossier uses goes through `buildActivityListWhere`.
'src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx',
])
it('keeps the scope fragment out of re-spellings', () => {

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import type { Municipality } from '@/payload-types'
import {
CITY_MUNICIPALITY_ID,
buildCityMunicipalityDoc,
cityMatchesFilter,
cityTerritorialClass,
insertCityAtNativeSortPosition,
} from '@/utilities/municipality/municipalityCityRow'
import type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl'
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
/**
* B178 — the city row of the municipality list behaves like a normal entity:
* `cityMatchesFilter` mirrors `buildMunicipalityListWhere` semantics over the
* city's virtual values, and `insertCityAtNativeSortPosition` places it under
* native sorts without re-sorting the SQL-ordered docs.
*/
const emptyState = (): MunicipalityListState => ({ page: 1 })
describe('cityMatchesFilter', () => {
it('selects the city in the default recorte', () => {
expect(cityMatchesFilter(emptyState())).toBe(true)
})
it('matches the free-text search the same way the DB name contains does', () => {
expect(cityMatchesFilter({ ...emptyState(), q: 'salvador' })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), q: 'Salvador' })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), q: 'cidade' })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), q: 'são paulo' })).toBe(false)
// A bare zone number is a zone search — the city has no zoneNumber.
expect(cityMatchesFilter({ ...emptyState(), q: '3' })).toBe(false)
})
it('matches the region filter with its own territory', () => {
expect(cityMatchesFilter({ ...emptyState(), regions: ['Metropolitano de Salvador'] })).toBe(
true,
)
expect(cityMatchesFilter({ ...emptyState(), regions: ['Chapada Diamantina'] })).toBe(false)
})
it('matches the slug filter only for its own slug', () => {
expect(cityMatchesFilter({ ...emptyState(), slugs: ['salvador'] })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), slugs: ['salvador-ze-1'] })).toBe(false)
})
it('never matches filters the city has no value for', () => {
expect(cityMatchesFilter({ ...emptyState(), advisors: [1] })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), priority: 'alta' })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), trends: ['favoravel'] })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), levels: ['n0'] })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), stateDeputies: [1] })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), leaderships: [1] })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), parties: ['PT'] })).toBe(false)
})
it('matches the absence sentinels like a municipality without those links', () => {
expect(cityMatchesFilter({ ...emptyState(), coverage: 'sem_assessor' })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), coverage: 'com_assessor' })).toBe(false)
expect(cityMatchesFilter({ ...emptyState(), levels: ['sem_nivel'] })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), stateDeputies: ['sem_dobradinha'] })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), leaderships: ['sem_lideranca'] })).toBe(true)
expect(cityMatchesFilter({ ...emptyState(), parties: ['sem_partido'] })).toBe(true)
// A mixed selection still fails: named values cannot be satisfied.
expect(cityMatchesFilter({ ...emptyState(), levels: ['n0', 'sem_nivel'] })).toBe(false)
})
it('matches the class filter only for its own aggregate class', () => {
const ownClass = cityTerritorialClass().class
expect(cityMatchesFilter({ ...emptyState(), classes: [ownClass] })).toBe(true)
const allClasses: MunicipalityTerritorialClass[] = [
'reduto',
'expansao',
'manutencao',
'marginal',
'sem_base',
]
for (const other of allClasses.filter((value) => value !== ownClass)) {
expect(cityMatchesFilter({ ...emptyState(), classes: [other] })).toBe(false)
}
})
})
const cityDoc = buildCityMunicipalityDoc()
describe('buildCityMunicipalityDoc', () => {
it('uses a sentinel id and virtual values only', () => {
expect(cityDoc.id).toBe(CITY_MUNICIPALITY_ID)
expect(cityDoc.slug).toBe('salvador')
expect(cityDoc.name).toBe('Salvador (cidade)')
expect(cityDoc.advisors).toEqual([])
expect(cityDoc.stateDeputies).toEqual([])
expect(cityDoc.engagementLevel).toBeNull()
expect(cityDoc.expectedVotes).toBeUndefined()
expect(cityDoc.lastUpdateAt).toBeNull()
})
})
const doc = (name: string, region: string) =>
({ slug: `slug-${name}`, name, region }) as Municipality
describe('insertCityAtNativeSortPosition', () => {
const docs = [
doc('Alagoinhas', 'Litoral Norte e Agreste Baiano'),
doc('Salvador — ZE 1', 'Metropolitano de Salvador'),
doc('Salvador — ZE 19', 'Metropolitano de Salvador'),
doc('Teixeira de Freitas', 'Extremo Sul'),
]
it('places the city inside the Salvador block on name asc without re-sorting the rest', () => {
const sorted = insertCityAtNativeSortPosition(docs, cityDoc, 'name', 'asc')
const names = sorted.map((entry) => entry.name)
// Node's pt-BR collation orders "Salvador — ZE N" before "Salvador
// (cidade)" (the em dash + digit collate ahead of the parenthesized word),
// so the city lands right after its zones — still inside the block, with
// every other row keeping the SQL order.
expect(names).toEqual([
'Alagoinhas',
'Salvador — ZE 1',
'Salvador — ZE 19',
'Salvador (cidade)',
'Teixeira de Freitas',
])
})
it('places the city first within the block on name desc', () => {
const sorted = insertCityAtNativeSortPosition(docs, cityDoc, 'name', 'desc')
const names = sorted.map((entry) => entry.name)
expect(names[0]).toBe('Salvador (cidade)')
expect(names.indexOf('Salvador — ZE 1')).toBeLessThan(names.indexOf('Teixeira de Freitas'))
})
it('keeps the SQL order of the other rows untouched', () => {
const sorted = insertCityAtNativeSortPosition(docs, cityDoc, 'name', 'asc')
const zoneIndex = sorted.findIndex((entry) => entry.name === 'Salvador — ZE 1')
expect(sorted[zoneIndex + 1]?.name).toBe('Salvador — ZE 19')
})
it('sorts the city to the end for null-valued native keys', () => {
const sorted = insertCityAtNativeSortPosition(docs, cityDoc, 'lastUpdateAt', 'asc')
expect(sorted[sorted.length - 1]?.slug).toBe(cityDoc.slug)
const sortedDesc = insertCityAtNativeSortPosition(docs, cityDoc, 'trend', 'desc')
expect(sortedDesc[sortedDesc.length - 1]?.slug).toBe(cityDoc.slug)
})
})

View file

@ -232,3 +232,19 @@ describe('municipality omnibox (B127)', () => {
})
})
})
describe('municipality omnibox — city slug (B178)', () => {
it('labels the city slug chip with the aggregate name, never the raw slug', () => {
const chips = buildMunicipalityOmniboxChips({
state: parseMunicipalityListParams({ slug: 'salvador' }),
scenario: 'central',
showStaffFilters: true,
advisorLabelsById: new Map(),
stateDeputyLabelsById: new Map(),
leadershipLabelsById: new Map(),
})
expect(chips.find((chip) => chip.id === 'slug:salvador')?.label).toBe(
'Município: Salvador (cidade)',
)
})
})

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { isCitySlug, salvadorCity } from '@/lib/salvadorCity'
import {
cityCompetitiveRank,
cityFederalBaseline,
cityVoteRankEntry,
} from '@/utilities/municipality/salvadorCityAggregates'
/**
* B178 — the Salvador city aggregate is a DERIVED view of the 19 zone
* municipalities. The guardrail de dupla contagem lives here: the city never
* carries its own votes (its baseline must equal the sum of its zone keys),
* and it never enters the catalog or the election artifact, so no aggregate
* consumer that iterates either can double count it.
*/
describe('salvador city aggregate', () => {
it('derives exactly the 19 Salvador zone slugs from the catalog', () => {
expect(salvadorCity.zoneSlugs).toHaveLength(19)
expect(salvadorCity.tseZones).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
])
for (const slug of salvadorCity.zoneSlugs) {
expect(slug).toMatch(/^salvador-ze-\d+$/)
expect(municipalityCatalog.some((entry) => entry.slug === slug)).toBe(true)
}
})
it('never becomes a catalog unit or an artifact key (guardrail estrutural)', () => {
expect(municipalityCatalog.some((entry) => entry.slug === salvadorCity.slug)).toBe(false)
expect(municipalityCatalog).toHaveLength(435)
expect(federalBaselineMunicipalitySlugs().includes(salvadorCity.slug)).toBe(false)
})
it('statewide totals are unchanged by the city (it is computed, never stored)', () => {
let sumOverCatalog = 0
for (const slug of federalBaselineMunicipalitySlugs()) {
sumOverCatalog += getMunicipalityFederalBaseline(slug).votesByYear['2022'] ?? 0
}
expect(getStatewideFederalTotals(2022).ownVotes).toBe(sumOverCatalog)
expect(cityFederalBaseline().votesByYear['2022']).toBeGreaterThan(0)
expect(cityFederalBaseline().votesByYear['2022']).toBeLessThan(sumOverCatalog)
})
it('city baseline equals the sum of its zone baselines for every year (no new votes)', () => {
const city = cityFederalBaseline()
const zoneKeys = salvadorCity.zoneSlugs
for (const year of ['2014', '2018', '2022']) {
const summedVotes = zoneKeys.reduce(
(total, slug) => total + (getMunicipalityFederalBaseline(slug).votesByYear[year] ?? 0),
0,
)
const summedValid = zoneKeys.reduce(
(total, slug) => total + (getMunicipalityFederalBaseline(slug).validVotesByYear[year] ?? 0),
0,
)
const summedCampo = zoneKeys.reduce(
(total, slug) =>
total + (getMunicipalityFederalBaseline(slug).campoFederalVotesByYear[year] ?? 0),
0,
)
expect(city.votesByYear[year]).toBe(summedVotes)
expect(city.validVotesByYear[year]).toBe(summedValid)
expect(city.campoFederalVotesByYear[year]).toBe(summedCampo)
}
const summedTally = zoneKeys.reduce(
(total, slug) => {
const tally = getMunicipalityFederalBaseline(slug).federalTallyByYear['2022']
return {
comparecimento: total.comparecimento + (tally?.comparecimento ?? 0),
votosValidos: total.votosValidos + (tally?.votosValidos ?? 0),
votosBranco: total.votosBranco + (tally?.votosBranco ?? 0),
votosNulo: total.votosNulo + (tally?.votosNulo ?? 0),
}
},
{ comparecimento: 0, votosValidos: 0, votosBranco: 0, votosNulo: 0 },
)
expect(city.federalTallyByYear['2022']).toEqual(summedTally)
})
it('carries one whole-city competitive placement ("12º de 663")', () => {
const placement = cityCompetitiveRank(ELECTION_YEAR_2022)
expect(placement).not.toBeNull()
if (!placement) return
expect(placement.rank).toBeGreaterThanOrEqual(1)
expect(placement.candidates).toBeGreaterThanOrEqual(placement.rank)
const entry = cityVoteRankEntry(ELECTION_YEAR_2022)
expect(entry).not.toBeNull()
expect(entry?.votes).toBe(cityFederalBaseline().votesByYear['2022'])
expect(entry?.rank).toBe(placement.rank)
expect(entry?.totalUnits).toBe(placement.candidates)
expect(entry?.share).toBeGreaterThan(0)
expect(entry?.share).toBeLessThan(1)
})
it('returns no rank entry for a year without placement (never a fabricated last place)', () => {
expect(cityVoteRankEntry(1999)).toBeNull()
})
it('isCitySlug only recognizes the city slug', () => {
expect(isCitySlug('salvador')).toBe(true)
expect(isCitySlug('salvador-ze-1')).toBe(false)
expect(isCitySlug('feira-de-santana')).toBe(false)
})
})