From de66fe3e3f59bcf520af0f658439af19b616ce28 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 10:07:05 -0300 Subject: [PATCH] =?UTF-8?q?feat(B178):=20Salvador=20como=20cidade=20no=20/?= =?UTF-8?q?campanha=20=E2=80=94=20p=C3=A1gina=20virtual,=20linha=20comum?= =?UTF-8?q?=20na=20lista=20e=20busca,=20sem=20dupla=20contagem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../salvador-pagina-detalhe-cidade-impl.md | 105 +++++++ .../municipios/[slug]/CityDetailSections.tsx | 292 ++++++++++++++++++ .../campanha/(app)/municipios/[slug]/page.tsx | 46 +++ .../municipality/MunicipalityList.tsx | 118 ++++--- .../MunicipalityListMobileCards.tsx | 134 +++++--- .../municipality/MunicipalityTabNav.tsx | 5 +- src/lib/salvadorCity.ts | 91 ++++++ src/utilities/ai/campaignNavigationUrls.ts | 5 +- src/utilities/ai/tools/searchEntities.ts | 15 + .../municipality/municipalityCityRow.ts | 143 +++++++++ .../municipality/municipalityDetailTabUi.ts | 12 + .../municipalityElectionGeography.ts | 11 + .../municipality/municipalityListFilters.ts | 22 +- .../municipality/municipalityListUrl.ts | 11 +- .../municipality/municipalityOmnibox.ts | 4 +- .../municipality/municipalityPageData.ts | 123 +++++++- .../municipality/municipalityViewModels.ts | 11 +- .../municipality/salvadorCityAggregates.ts | 154 +++++++++ tests/int/municipalityPageData.int.spec.ts | 162 ++++++++++ tests/unit/campaignComponents.unit.spec.ts | 9 + .../unit/campaignNavigationUrls.unit.spec.ts | 8 + tests/unit/codebaseConventions.unit.spec.ts | 4 + tests/unit/municipalityCityRow.unit.spec.ts | 147 +++++++++ tests/unit/municipalityOmnibox.unit.spec.ts | 16 + tests/unit/salvadorCity.unit.spec.ts | 115 +++++++ 25 files changed, 1648 insertions(+), 115 deletions(-) create mode 100644 docs/plans/salvador-pagina-detalhe-cidade-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx create mode 100644 src/lib/salvadorCity.ts create mode 100644 src/utilities/municipality/municipalityCityRow.ts create mode 100644 src/utilities/municipality/salvadorCityAggregates.ts create mode 100644 tests/unit/municipalityCityRow.unit.spec.ts create mode 100644 tests/unit/salvadorCity.unit.spec.ts diff --git a/docs/plans/salvador-pagina-detalhe-cidade-impl.md b/docs/plans/salvador-pagina-detalhe-cidade-impl.md new file mode 100644 index 00000000..ae5632ed --- /dev/null +++ b/docs/plans/salvador-pagina-detalhe-cidade-impl.md @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx new file mode 100644 index 00000000..b199516d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx @@ -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 ( +
+
+
+

+ Votação de {BASELINE_TICKET_2022.candidate.name} ({BASELINE_TICKET_2022.candidate.party} + ) +

+

+ Soma das 19 zonas eleitorais · votos nominais para deputado federal (1º turno) — fonte + TSE. +

+
+ +
+ {series.map(({ year, votes, share, competitive }) => ( +
+
Eleição {year}
+
{formatElectionNumber(votes)}
+
+ {formatVoteSharePercent(share)} + {competitive ? ( + + {formatPlacementOrdinal(competitive.rank)} de{' '} + {formatElectionNumber(competitive.candidates)} + + ) : ( + — + )} +
+
+ ))} +
+ + {evolution.status !== 'noBaseline' ? ( +

Evolução de votos: {evolution.message}

+ ) : null} + + {tally2022 ? ( +
+

Comparativos locais

+

+ 2022: {formatElectionNumber(tally2022.comparecimento)} compareceram ·{' '} + {formatElectionNumber(tally2022.votosValidos)} votos válidos +

+ {tally2022.votosValidos > 0 ? ( +

+ Dominância local:{' '} + {formatVoteSharePercent( + (baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0) / tally2022.votosValidos, + )}{' '} + dos válidos na capital (distinto da concentração acima). +

+ ) : null} +
+ ) : null} + + {majoritarian ? ( +

+ Chapa 2022: Lula {formatElectionNumber(majoritarian.president.votes)} votos · Jerônimo{' '} + {formatElectionNumber(majoritarian.governor.votes)} votos +

+ ) : null} +
+ +
+
+

+ Onde na capital o candidato é mais forte ({ELECTION_YEAR_2022}) +

+

+ A operação da campanha é por zona — esta é a leitura agregada, não uma unidade nova. +

+
+
    + {topZones.map(({ entry, votes }, index) => ( +
  1. + + ZE {entry.zoneNumber} + + + {formatElectionNumber(votes)} + + +
  2. + ))} +
+
+ +
+

+ Entradas da capital +

+
+
+

As 19 zonas

+

+ Para operar, entre numa zona eleitoral — cada uma tem ficha, dossiê e linha próprias. +

+ + Ver as 19 zonas na lista + +
+
+

Demandas

+

+ As demandas são registradas por zona eleitoral. +

+ + Ver Demandas + +
+
+

Agenda na capital

+ {upcoming.length ? ( +
    + {upcoming.map((activity) => ( +
  1. + {activity.title} +
  2. + ))} +
+ ) : ( +

+ Nenhuma atividade confirmada nas zonas da capital. +

+ )} + + Ver Agenda + +
+
+

Mapa

+

+ No mapa, Salvador continua pintada por zona; a cidade segue como base não-interativa. +

+ + Ver mapa + +
+
+
+
+ ) +} + +/** B178 — demands of the capital link out, same pattern as the municipality page. */ +export const CityDemandsTab = () => ( +
+

+ As demandas da capital aparecem em{' '} + + Demandas + + . Elas são registradas por zona eleitoral — a capital é uma leitura agregada das 19 zonas. +

+
+) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index 5c311fc5..bd7ca040 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -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 ( + + + + {cityTab === 'overview' ? : null} + {cityTab === 'elections' ? ( + }> + + + ) : null} + {cityTab === 'demands' ? : null} + + + ) + } + let context try { context = await resolveAccessibleMunicipalityContext(payload, user, slug) diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 063fb82b..86b8e7a5 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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 = ({ ) + // 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 = — + + const renderTrendCell = ( + municipality: MunicipalityListViewModel, + triggerPresentation: 'compact' | 'adaptive', + ) => + municipality.isCity ? ( + Não registrada + ) : ( + 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} - {municipality.priority === 'alta' && isStaffView ? ( + {municipality.isCity ? ( + + Cidade + + ) : municipality.priority === 'alta' && isStaffView ? ( ) : null} - + {municipality.isCity ? ( + SALVADOR_CITY_AGGREGATE_LABEL + ) : ( + + )} ), @@ -339,13 +371,17 @@ const municipalityListColumns = ({ cellClassName: 'relative overflow-visible align-middle text-center tabular-nums', cell: (municipality) => (
- + {municipality.isCity ? ( + cityDash + ) : ( + + )}
), }, @@ -363,7 +399,9 @@ const municipalityListColumns = ({ /> ), cell: (municipality) => - canMoveEngagementLevel ? ( + municipality.isCity ? ( + cityDash + ) : canMoveEngagementLevel ? ( - isCoordinator ? ( + municipality.isCity ? ( + cityDash + ) : isCoordinator ? ( ), - cell: (municipality) => renderTrendControl(municipality, 'adaptive'), + cell: (municipality) => renderTrendCell(municipality, 'adaptive'), }, { id: 'leaderships', @@ -473,16 +513,19 @@ const municipalityListColumns = ({ ), cellClassName: responsiveColumnClassName.leaderships, - cell: (municipality) => ( - - ), + cell: (municipality) => + municipality.isCity ? ( + cityDash + ) : ( + + ), }, { // 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) => ( - - ), + cell: (municipality) => + municipality.isCity ? ( + cityDash + ) : ( + + ), }, { @@ -545,7 +591,7 @@ const municipalityListColumns = ({ {municipalityColumnLabels.lastSignal} ), - cell: (municipality) => renderSignalControl(municipality, 'adaptive'), + cell: (municipality) => renderSignalCell(municipality, 'adaptive'), }, ...(trendIsHidden || signalIsHidden ? ([ @@ -561,8 +607,8 @@ const municipalityListColumns = ({ cellClassName: 'text-right', cell: (municipality) => (
- {trendIsHidden ? renderTrendControl(municipality, 'compact') : null} - {signalIsHidden ? renderSignalControl(municipality, 'compact') : null} + {trendIsHidden ? renderTrendCell(municipality, 'compact') : null} + {signalIsHidden ? renderSignalCell(municipality, 'compact') : null}
), }, diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index 8df432a3..02b5997e 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 (
-

+

{municipality.name} + {isCity ? Cidade : null}

- - - - {zoneSuffix ? ` ${zoneSuffix}` : null} + {isCity ? ( + SALVADOR_CITY_AGGREGATE_LABEL + ) : ( + <> + + + + {zoneSuffix ? ` ${zoneSuffix}` : null} + + )}

{position ? ( ) : null}
- {isPriority && isStaffView ? ( + {!isCity && isPriority && isStaffView ? ( ) : null}
@@ -143,19 +153,25 @@ export const MunicipalityListMobileCards = ({
Votos estimados
- + {isCity ? ( + — + ) : ( + + )}
Nível
- {canMoveEngagementLevel ? ( + {isCity ? ( + — + ) : canMoveEngagementLevel ? (
Tendência
- + {isCity ? ( + Não registrada + ) : ( + + )}
Última atualização
- - - + {isCity ? ( + + ) : ( + + + + )}
Assessores
- {isCoordinator ? ( + {isCity ? ( + — + ) : isCoordinator ? (
Lideranças
- + {isCity ? ( + — + ) : ( + + )}
{/* B176 — staff-wide since 2026-08-09; the write stays scoped to @@ -240,15 +270,19 @@ export const MunicipalityListMobileCards = ({
Dobradinhas
- + {isCity ? ( + — + ) : ( + + )}
) : null} diff --git a/src/components/campaign/municipality/MunicipalityTabNav.tsx b/src/components/campaign/municipality/MunicipalityTabNav.tsx index abce4643..7c80934a 100644 --- a/src/components/campaign/municipality/MunicipalityTabNav.tsx +++ b/src/components/campaign/municipality/MunicipalityTabNav.tsx @@ -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[] }) => (