mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
commit
f1a8332835
25 changed files with 1648 additions and 115 deletions
105
docs/plans/salvador-pagina-detalhe-cidade-impl.md
Normal file
105
docs/plans/salvador-pagina-detalhe-cidade-impl.md
Normal 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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
91
src/lib/salvadorCity.ts
Normal 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()
|
||||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
143
src/utilities/municipality/municipalityCityRow.ts
Normal file
143
src/utilities/municipality/municipalityCityRow.ts
Normal 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)]
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
154
src/utilities/municipality/salvadorCityAggregates.ts
Normal file
154
src/utilities/municipality/salvadorCityAggregates.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
147
tests/unit/municipalityCityRow.unit.spec.ts
Normal file
147
tests/unit/municipalityCityRow.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
115
tests/unit/salvadorCity.unit.spec.ts
Normal file
115
tests/unit/salvadorCity.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue