From d8ee2dbafad631667e34adf6bf7efc45a712626a Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Sun, 9 Aug 2026 10:40:18 -0300
Subject: [PATCH] =?UTF-8?q?fix(B178):=20review=20do=20simplify=20=E2=80=94?=
=?UTF-8?q?=20nulls=20nativos=20em=20DESC,=20mapa=20sem=20slug=20da=20cida?=
=?UTF-8?q?de,=20sr-only=20morto,=20sinal=20mobile,=20pagina=C3=A7=C3=A3o/?=
=?UTF-8?q?edges=20testados?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../municipios/[slug]/CityDetailSections.tsx | 30 +++++--
.../campanha/(app)/municipios/[slug]/page.tsx | 4 +-
.../municipality/MunicipalityList.tsx | 4 +-
.../MunicipalityListMobileCards.tsx | 9 ++-
.../municipality/MunicipalityTabNav.tsx | 5 +-
src/lib/salvadorCity.ts | 15 ++--
.../municipality/municipalityCityRow.ts | 7 +-
.../municipality/municipalityMapData.ts | 16 +++-
.../municipality/municipalityPageData.ts | 11 ++-
.../municipality/salvadorCityAggregates.ts | 15 +++-
tests/int/municipalityPageData.int.spec.ts | 79 +++++++++++++++++++
tests/unit/municipalityCityRow.unit.spec.ts | 10 ++-
tests/unit/salvadorCity.unit.spec.ts | 13 +++
13 files changed, 180 insertions(+), 38 deletions(-)
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx
index b199516d..fb8890c3 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx
@@ -20,6 +20,11 @@ import {
const CITY_SERIES_YEARS = [2014, 2018, ELECTION_YEAR_2022] as const
+const activityDateFormatter = new Intl.DateTimeFormat('pt-BR', {
+ day: '2-digit',
+ month: 'short',
+})
+
const now = () => new Date()
/**
@@ -187,7 +192,7 @@ export const CityOverviewTab = async ({
Agenda na capital
- {upcoming.length ? (
+ {cityZoneIDs.length === 0 ? (
+
+ A capital não está no seu recorte de atuação.
+
+ ) : upcoming.length ? (
{upcoming.map((activity) => (
-
- {activity.title}
+
+
+
+ {activity.startAt
+ ? activityDateFormatter.format(new Date(activity.startAt))
+ : null}
+ {' '}
+ {activity.title}
+
))}
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
index bd7ca040..a25ca158 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
@@ -38,11 +38,12 @@ const CITY_PAGE_SUBTITLE = 'Metropolitano de Salvador · ZE 1–19 · capital (a
export async function generateMetadata({ params }: MunicipalityDetailPageProps) {
const { slug } = await params
+ // Same gate as the page itself: the city metadata is staff-only too.
+ const user = await requireCampaignPageActor({ gate: 'noLeader' })
if (isCitySlug(slug)) {
return campaignPageMetadata({ title: salvadorCity.name, subtitle: CITY_PAGE_SUBTITLE })
}
const payload = await getPayload({ config })
- const user = await requireCampaignPageActor({ gate: 'noLeader' })
try {
const context = await resolveAccessibleMunicipalityContext(payload, user, slug)
@@ -86,6 +87,7 @@ export default async function MunicipalityDetailPage({
municipalitySlug={salvadorCity.slug}
searchParams={rawSearchParams}
tabs={cityMunicipalityDetailTabs}
+ ariaLabel="Seções da capital"
/>
{cityTab === 'overview' ?
: null}
{cityTab === 'elections' ? (
diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx
index 86b8e7a5..236ab233 100644
--- a/src/components/campaign/municipality/MunicipalityList.tsx
+++ b/src/components/campaign/municipality/MunicipalityList.tsx
@@ -323,7 +323,9 @@ const municipalityListColumns = ({
{municipality.isCity ? (
- SALVADOR_CITY_AGGREGATE_LABEL
+ <>
+ {SALVADOR_CITY_AGGREGATE_LABEL} · {municipality.region}
+ >
) : (
)}
diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx
index 02b5997e..1a8352de 100644
--- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx
+++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx
@@ -116,7 +116,9 @@ export const MunicipalityListMobileCards = ({
{isCity ? (
- SALVADOR_CITY_AGGREGATE_LABEL
+ <>
+ {SALVADOR_CITY_AGGREGATE_LABEL} · {region}
+ >
) : (
<>
@@ -209,7 +211,10 @@ export const MunicipalityListMobileCards = ({
Última atualização
{isCity ? (
-
+ // The city never has a signal (nothing is recorded on the
+ // aggregate) — a plain dash, not the cold-signal alert the
+ // readout would paint for a null timestamp.
+ —
) : (
(
=>
- municipalityCatalogEntriesForCity(SALVADOR_CITY)
+/** The 19 ZE entries, ordered by zone number (catalog order). */
+export const salvadorZoneCatalogEntries = (): ReturnType<
+ typeof municipalityCatalogEntriesForCity
+> => municipalityCatalogEntriesForCity(SALVADOR_CITY)
const buildSalvadorCityDescriptor = (): SalvadorCityDescriptor => {
- const zoneEntries = salvadorZoneEntries()
+ const zoneEntries = salvadorZoneCatalogEntries()
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.
@@ -84,8 +86,3 @@ 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()
diff --git a/src/utilities/municipality/municipalityCityRow.ts b/src/utilities/municipality/municipalityCityRow.ts
index 2bef700b..77b5e8a0 100644
--- a/src/utilities/municipality/municipalityCityRow.ts
+++ b/src/utilities/municipality/municipalityCityRow.ts
@@ -121,7 +121,8 @@ export const buildCityMunicipalityDoc = (): Municipality => ({
* 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).
+ * and follow Postgres' native null ordering (NULLS LAST on ASC, NULLS FIRST on
+ * DESC), so the row sits with the other municipalities that have no value.
*/
export const insertCityAtNativeSortPosition = (
docs: readonly Municipality[],
@@ -129,7 +130,9 @@ export const insertCityAtNativeSortPosition = (
sortKey: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): Municipality[] => {
- if (sortKey !== 'name' && sortKey !== 'region') return [...docs, city]
+ if (sortKey !== 'name' && sortKey !== 'region') {
+ return dir === 'desc' ? [city, ...docs] : [...docs, city]
+ }
const compareToCity = (doc: Municipality): number =>
sortKey === 'name'
? doc.name.localeCompare(city.name, 'pt-BR')
diff --git a/src/utilities/municipality/municipalityMapData.ts b/src/utilities/municipality/municipalityMapData.ts
index 091b2146..8e51462a 100644
--- a/src/utilities/municipality/municipalityMapData.ts
+++ b/src/utilities/municipality/municipalityMapData.ts
@@ -9,6 +9,7 @@ import {
} from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
+import { isCitySlug } from '@/lib/salvadorCity'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
@@ -255,14 +256,23 @@ export const loadMunicipalityMapBundle = async (
if (user.role === 'leader') return null
const state = parseMunicipalityListParams(searchParams)
+ // B178 — the virtual city slug must never narrow the map scope: the map
+ // only paints catalog units and the city has no polygon/map key, so a
+ // `?slug=salvador` recorte would silently empty the map. The list row is the
+ // surface for the aggregate; the map keeps its catalog-only contract.
+ const mapState: MunicipalityListState = (() => {
+ if (!state.slugs?.some(isCitySlug)) return state
+ const catalogSlugs = state.slugs.filter((slug) => !isCitySlug(slug))
+ return catalogSlugs.length ? { ...state, slugs: catalogSlugs } : { ...state, slugs: undefined }
+ })()
// B176 — the map shares the list URL, so a leadership/party recorte must
// carry its relation catalog into the very same `where` the list uses. Lazy
// per active filter, so the dashboard landing page pays no extra reads.
- const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, state)
+ const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, mapState)
const scope = await loadMunicipalityScope(
payload,
user,
- buildMunicipalityListWhere(state, relationCatalog),
+ buildMunicipalityListWhere(mapState, relationCatalog),
)
const municipalities = scopeMunicipalitiesFromDocs(scope.municipalities)
if (municipalities.length === 0) return null
@@ -270,7 +280,7 @@ export const loadMunicipalityMapBundle = async (
return buildMunicipalityMapBundleFromMunicipalities(
payload,
user,
- state,
+ mapState,
municipalities,
scope.pledgeAggregates,
)
diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts
index 15418f7d..ac57d42f 100644
--- a/src/utilities/municipality/municipalityPageData.ts
+++ b/src/utilities/municipality/municipalityPageData.ts
@@ -577,12 +577,11 @@ export const loadMunicipalityListPageBundle = async (
classMatches(municipality.slug),
)
: (listResult.docs as Municipality[])
- // 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
+ // B178 — the city row joins the in-memory recorte and enters the sort with
+ // its virtual values; native sorts position it explicitly.
+ // (`cityMatchesFilter` already rejected the city when the class filter
+ // excludes its aggregate class, so nothing extra to check here.)
+ const cityDoc = cityInRecorte ? buildCityMunicipalityDoc() : null
const allDocs = applyDerivedMunicipalitySort(
cityDoc ? [...scopedDocs, cityDoc] : scopedDocs,
sortKey,
diff --git a/src/utilities/municipality/salvadorCityAggregates.ts b/src/utilities/municipality/salvadorCityAggregates.ts
index 599c3f8e..67d86a28 100644
--- a/src/utilities/municipality/salvadorCityAggregates.ts
+++ b/src/utilities/municipality/salvadorCityAggregates.ts
@@ -25,10 +25,14 @@ import { salvadorCity } from '@/lib/salvadorCity'
*/
/**
- * The city baseline = the SUM of its 19 zone-slug baselines, folded once per
- * process (the artifact is immutable).
+ * The city baseline = the SUM of its 19 zone-slug baselines, folded ONCE per
+ * process (the artifact is immutable — same memoization as
+ * `getStatewideFederalTotals`). The city never carries its own votes: this is
+ * a derived view of the same cells the zones already hold.
*/
-export const cityFederalBaseline = (): MunicipalityFederalBaseline => {
+let cityBaselineCache: MunicipalityFederalBaseline | null = null
+
+const foldCityFederalBaseline = (): MunicipalityFederalBaseline => {
const total: MunicipalityFederalBaseline = {
votesByYear: {},
validVotesByYear: {},
@@ -107,6 +111,11 @@ export const cityFederalBaseline = (): MunicipalityFederalBaseline => {
return total
}
+export const cityFederalBaseline = (): MunicipalityFederalBaseline => {
+ if (!cityBaselineCache) cityBaselineCache = foldCityFederalBaseline()
+ return cityBaselineCache
+}
+
/** 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
diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts
index d635fdfc..4d6d2d49 100644
--- a/tests/int/municipalityPageData.int.spec.ts
+++ b/tests/int/municipalityPageData.int.spec.ts
@@ -609,6 +609,85 @@ describe('loadMunicipalityListPageBundle', () => {
expect(bundle.municipalities[cityIndex - 1]?.kind).toBe('zona')
})
+ it('orders the city row by its real summed votes under sort=votos', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+
+ const descending = await loadMunicipalityListPageBundle(payload, coordinator, {
+ q: 'salvador',
+ sort: 'votos',
+ })
+ expect(descending.municipalities[0]?.slug).toBe('salvador')
+ expect(descending.municipalities[0]?.isCity).toBe(true)
+
+ const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
+ q: 'salvador',
+ sort: 'votos',
+ dir: 'asc',
+ })
+ expect(ascending.municipalities[ascending.municipalities.length - 1]?.slug).toBe('salvador')
+ })
+
+ it('paginates the city row like a common row (last page) and beyond totalPages', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+
+ // Metropolitano de Salvador: catalog municipalities + the city row, sorted
+ // by name — the Salvador block (with the city) lands on the last page.
+ const metropolitanoCount =
+ municipalityCatalog.filter((entry) => entry.region === 'Metropolitano de Salvador').length + 1
+ const pageOne = await loadMunicipalityListPageBundle(payload, coordinator, {
+ region: ['Metropolitano de Salvador'],
+ sort: 'name',
+ })
+ expect(pageOne.totalDocs).toBe(metropolitanoCount)
+ expect(pageOne.totalPages).toBe(Math.ceil(metropolitanoCount / 25))
+ expect(pageOne.municipalities).toHaveLength(25)
+ expect(pageOne.municipalities.some((row) => row.slug === 'salvador')).toBe(false)
+
+ const lastPage = pageOne.totalPages
+ const pageLast = await loadMunicipalityListPageBundle(payload, coordinator, {
+ region: ['Metropolitano de Salvador'],
+ sort: 'name',
+ page: String(lastPage),
+ })
+ expect(pageLast.municipalities.some((row) => row.slug === 'salvador')).toBe(true)
+ expect(pageLast.municipalities).toHaveLength(metropolitanoCount - 25 * (lastPage - 1))
+
+ const beyond = await loadMunicipalityListPageBundle(payload, coordinator, {
+ region: ['Metropolitano de Salvador'],
+ sort: 'name',
+ page: String(lastPage + 1),
+ })
+ expect(beyond.municipalities).toHaveLength(0)
+ })
+
+ it('keeps the advisor rule conjunctive: city hidden without q, filtered by other filters', async () => {
+ const fixtures = campaignFixtures()
+ const advisor = await fixtures.createCampaignUser('advisor')
+ const administered = await fixtures.getMunicipality()
+ await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
+
+ // Slug recorte alone does not reveal the city to an advisor (only q does).
+ const bySlug = await loadMunicipalityListPageBundle(payload, advisor, {
+ slug: ['salvador'],
+ })
+ expect(cityRow(bySlug)).toBeUndefined()
+
+ // q + a filter the city passes keeps it; q + a named-value filter hides it.
+ const withAbsence = await loadMunicipalityListPageBundle(payload, advisor, {
+ q: 'salvador',
+ coverage: 'sem_assessor',
+ })
+ expect(cityRow(withAbsence)).toBeDefined()
+
+ const withNamed = await loadMunicipalityListPageBundle(payload, advisor, {
+ q: 'salvador',
+ level: ['n0'],
+ })
+ expect(cityRow(withNamed)).toBeUndefined()
+ })
+
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')
diff --git a/tests/unit/municipalityCityRow.unit.spec.ts b/tests/unit/municipalityCityRow.unit.spec.ts
index 44492cbd..d717f91b 100644
--- a/tests/unit/municipalityCityRow.unit.spec.ts
+++ b/tests/unit/municipalityCityRow.unit.spec.ts
@@ -138,10 +138,14 @@ describe('insertCityAtNativeSortPosition', () => {
expect(sorted[zoneIndex + 1]?.name).toBe('Salvador — ZE 19')
})
- it('sorts the city to the end for null-valued native keys', () => {
+ it('follows the native null ordering for null-valued keys (last on asc, first on desc)', () => {
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)
+ // Postgres puts NULLS FIRST on DESC — the city (no lastUpdateAt) sits with
+ // the other municipalities that have no value, not at the bottom.
+ const sortedDesc = insertCityAtNativeSortPosition(docs, cityDoc, 'lastUpdateAt', 'desc')
+ expect(sortedDesc[0]?.slug).toBe(cityDoc.slug)
+ const trendAsc = insertCityAtNativeSortPosition(docs, cityDoc, 'trend', 'asc')
+ expect(trendAsc[trendAsc.length - 1]?.slug).toBe(cityDoc.slug)
})
})
diff --git a/tests/unit/salvadorCity.unit.spec.ts b/tests/unit/salvadorCity.unit.spec.ts
index 746182eb..8332012c 100644
--- a/tests/unit/salvadorCity.unit.spec.ts
+++ b/tests/unit/salvadorCity.unit.spec.ts
@@ -1,3 +1,6 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
import { describe, expect, it } from 'vitest'
import {
@@ -112,4 +115,14 @@ describe('salvador city aggregate', () => {
expect(isCitySlug('salvador-ze-1')).toBe(false)
expect(isCitySlug('feira-de-santana')).toBe(false)
})
+
+ it('stays client-bundle-safe: the descriptor never imports the election artifact', () => {
+ // The 623 KB committed baseline must never ride the browser bundle — the
+ // client imports this module (list rows, omnibox, filter labels), so the
+ // artifact and `server-only` are banned from its IMPORTS; the folded
+ // aggregates live in the server-only `salvadorCityAggregates.ts`.
+ const source = readFileSync(join(process.cwd(), 'src/lib/salvadorCity.ts'), 'utf8')
+ expect(source).not.toMatch(/^import .*bahiaElectionAggregates/m)
+ expect(source).not.toMatch(/^import 'server-only'/m)
+ })
})