fix(B178): review do simplify — nulls nativos em DESC, mapa sem slug da cidade, sr-only morto, sinal mobile, paginação/edges testados

This commit is contained in:
Francisco Solla 2026-08-09 10:40:18 -03:00
parent de66fe3e3f
commit d8ee2dbafa
13 changed files with 180 additions and 38 deletions

View file

@ -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 ({
</p>
</div>
<ol className="flex flex-col">
{topZones.map(({ entry, votes }, index) => (
{topZones.map(({ entry, votes }) => (
<li key={entry.slug} className="flex items-center justify-between gap-3 py-1.5">
<Link
href={`/campanha/municipios/${entry.slug}`}
@ -198,9 +203,6 @@ export const CityOverviewTab = async ({
<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>
@ -240,11 +242,25 @@ export const CityOverviewTab = async ({
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Agenda na capital</p>
{upcoming.length ? (
{cityZoneIDs.length === 0 ? (
<p className="text-sm text-muted-foreground">
A capital não está no seu recorte de atuação.
</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 key={activity.id} className="text-sm">
<Link
href={`/campanha/atividades/${activity.slug}`}
className="text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
>
<span className="font-medium tabular-nums text-foreground">
{activity.startAt
? activityDateFormatter.format(new Date(activity.startAt))
: null}
</span>{' '}
{activity.title}
</Link>
</li>
))}
</ol>

View file

@ -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' ? <CityOverviewTab payload={payload} user={user} /> : null}
{cityTab === 'elections' ? (

View file

@ -323,7 +323,9 @@ const municipalityListColumns = ({
</div>
<span className="block truncate text-xs text-muted-foreground">
{municipality.isCity ? (
SALVADOR_CITY_AGGREGATE_LABEL
<>
{SALVADOR_CITY_AGGREGATE_LABEL} · {municipality.region}
</>
) : (
<TerritoryLink region={municipality.region} compact />
)}

View file

@ -116,7 +116,9 @@ export const MunicipalityListMobileCards = ({
</h3>
<p className="text-sm text-muted-foreground">
{isCity ? (
SALVADOR_CITY_AGGREGATE_LABEL
<>
{SALVADOR_CITY_AGGREGATE_LABEL} · {region}
</>
) : (
<>
<span className="relative">
@ -209,7 +211,10 @@ export const MunicipalityListMobileCards = ({
<dt className="text-muted-foreground">Última atualização</dt>
<dd>
{isCity ? (
<SignalAgeReadout lastSignalAt={null} layout="card" />
// 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.
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListUpdateControl
municipalityID={municipality.id}

View file

@ -22,15 +22,18 @@ export const MunicipalityTabNav = ({
municipalitySlug,
searchParams,
tabs = municipalityDetailTabs,
ariaLabel = 'Seções do município',
}: {
activeTab: MunicipalityDetailTab
municipalitySlug: string
searchParams: MunicipalityDetailSearchParams
/** B178 — the city page renders a reduced tab set (its data owners only). */
tabs?: readonly MunicipalityDetailTab[]
/** B178 — the city page labels the same nav as the capital's sections. */
ariaLabel?: string
}) => (
<nav
aria-label="Seções do município"
aria-label={ariaLabel}
className="h-14 min-w-0 overflow-x-auto overflow-y-hidden border-b print:hidden"
>
<ul

View file

@ -30,7 +30,7 @@ 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 const SALVADOR_CITY_AGGREGATE_LABEL = 'Agregado das 19 zonas' as const
export type SalvadorCityDescriptor = {
slug: typeof SALVADOR_CITY_SLUG
@ -45,11 +45,13 @@ export type SalvadorCityDescriptor = {
tseZones: readonly number[]
}
const salvadorZoneEntries = (): ReturnType<typeof municipalityCatalogEntriesForCity> =>
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()

View file

@ -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')

View file

@ -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,
)

View file

@ -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,

View file

@ -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

View file

@ -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')

View file

@ -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)
})
})

View file

@ -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)
})
})