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:
parent
de66fe3e3f
commit
d8ee2dbafa
13 changed files with 180 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue