Pass 2 W1-D1: sistema de listas (column defs + CampaignTable) + split do municipalityUi + B16+

- CampaignTable/CampaignTableHead: colunas como dado (id, head island, cell, seams B17 mandatory/defaultVisible); colSpan derivado
- CampaignListFooter: contagem + paginacao compartilhadas
- MunicipalityList reescrita sobre column defs; cards mobile inalterados
- municipalityUi.ts (765/59 exports) dividido: municipalityListUrl / municipalityListFilters / municipalityLabels / municipalitySignal; contrato de URL byte-identico (pins verdes)
- B16+ absorvido: useOptimistic no HeaderFilter (onNavigate dentro da transition), hrefs de opcao via serializador canonico confiavel (1 serializacao por opcao, pin de byte-igualdade), facet de municipio como slugs rotulados no cliente (-20KB), facets no mesmo Promise.all (seed por promise), catalogo DRY, clearMunicipalityListFilters
- timeout explicito no int test de facets (2 bundles full-scope sob suite 47-way)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 14:13:04 -03:00
parent dc9a39f800
commit 03bf54dea2
24 changed files with 1067 additions and 680 deletions

View file

@ -44,7 +44,7 @@ import {
import {
formatMunicipalityGeographyLabel,
municipalityKindLabels,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityLabels'
import {
loadMunicipalityUpdatesFeed,
parseMunicipalityUpdateFeedParams,

View file

@ -6,7 +6,7 @@ import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
@ -20,16 +20,17 @@ import {
isCampaignStaff,
} from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
import {
buildMunicipalityListVisitHref,
buildMunicipalityListVisitLabel,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
buildMunicipalityListVisitHref,
buildMunicipalityListVisitLabel,
getCampaignScopeLabel,
municipalityFilterOptionsForSlugs,
resolveMunicipalityListUrl,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListUrl'
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import {
assignMunicipalityAdvisorsFormAction,
@ -83,7 +84,9 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
const selectedAdvisorIDs = new Set(state.advisors ?? [])
const columnFilterOptions = {
name: municipalityFilterOptionsForSlugs(filterFacets.slugs),
// Bare slugs: the filter popover labels them from the catalog on the
// client, so the RSC payload never carries 435 name pairs (B16+).
name: filterFacets.slugs,
region: filterFacets.regions.map((region) => ({ value: region, label: region })),
advisor: filterFacets.advisorIDs
.flatMap((id) => {
@ -130,16 +133,14 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
state={state}
/>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'município encontrado' : 'municípios encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildMunicipalityListHref(state, page)}
/>
</div>
<CampaignListFooter
totalDocs={totalDocs}
singular="município encontrado"
plural="municípios encontrados"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildMunicipalityListHref(state, page)}
/>
</>
)

View file

@ -0,0 +1,31 @@
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
/**
* The count line + pagination every campaign list renders under its results.
* Server component: `hrefForPage` stays on the server (the pagination anchors
* are the client leaves).
*/
export const CampaignListFooter = ({
totalDocs,
singular,
plural,
page,
totalPages,
hrefForPage,
}: {
totalDocs: number
/** e.g. "município encontrado" */
singular: string
/** e.g. "municípios encontrados" */
plural: string
page: number
totalPages: number
hrefForPage: (page: number) => string
}) => (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? singular : plural}
</p>
<CampaignListPagination page={page} totalPages={totalPages} hrefForPage={hrefForPage} />
</div>
)

View file

@ -49,9 +49,10 @@ export const CampaignTransitionAnchor = forwardRef<
replace?: boolean
scroll?: boolean
/**
* Optimistic hook, fired only after this click commits to `href`: a control
* whose `href` derives from optimistic state must not invert itself before
* the navigation it is about to trigger.
* Optimistic hook, fired inside the navigation transition (so
* `useOptimistic` setters are legal here) and only after this click commits
* to `href`: a control whose `href` derives from optimistic state must not
* invert itself before the navigation it is about to trigger.
*/
onNavigate?: () => void
}
@ -81,8 +82,8 @@ export const CampaignTransitionAnchor = forwardRef<
return
}
event.preventDefault()
onNavigate?.()
startTransition(() => {
onNavigate?.()
if (replace) router.replace(href, { scroll })
else router.push(href, { scroll })
})

View file

@ -0,0 +1,126 @@
import { Fragment, type ReactNode } from 'react'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { cn } from '@/lib/utils'
/**
* Campaign list-table system (Pass 2 W1): columns are data, the table is a
* server component, interactivity lives in head/cell islands referenced by the
* definitions. B17 (column picker) toggles visibility over `id`s; E10/E14 add
* a definition instead of a new table.
*/
export type CampaignTableColumn<Row> = {
/** Stable identifier — the future column-picker key (B17). */
id: string
/**
* Full `<th>` node. Plain labels use `<CampaignTableHead>`; rich columns
* render their own head island (e.g. `MunicipalitySortableHead`), which owns
* `aria-sort` and the filter popover.
*/
head: ReactNode
cell: (row: Row) => ReactNode
cellClassName?: string | ((row: Row) => string | undefined)
/** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */
mandatory?: boolean
defaultVisible?: boolean
}
/** Plain (non-sortable) header cell for column definitions. */
export const CampaignTableHead = ({
align = 'left',
className,
children,
}: {
align?: 'left' | 'center' | 'right'
className?: string
children?: ReactNode
}) => (
<TableHead
className={cn(
'text-muted-foreground',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
className,
)}
>
{children}
</TableHead>
)
type CampaignTableProps<Row> = {
columns: Array<CampaignTableColumn<Row>>
rows: readonly Row[]
rowKey: (row: Row) => string | number
/** Rendered inside a full-width row when there are no rows — the header chrome stays mounted. */
empty?: ReactNode
/** Screen-reader table description. */
caption?: ReactNode
/** Outer wrapper (border/rounding live here). */
className?: string
/** `ui/Table` scroll container override (e.g. municipality's `overflow-x-visible`). */
containerClassName?: string
/** `<thead>` override (e.g. sticky headers). */
headerClassName?: string
rowClassName?: string | ((row: Row) => string | undefined)
}
export const CampaignTable = <Row,>({
columns,
rows,
rowKey,
empty,
caption,
className,
containerClassName,
headerClassName,
rowClassName,
}: CampaignTableProps<Row>) => (
<div className={cn('overflow-hidden rounded-xl border', className)}>
<Table containerClassName={containerClassName}>
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
<TableHeader className={headerClassName}>
<TableRow>
{columns.map((column) => (
<Fragment key={column.id}>{column.head}</Fragment>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && empty ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="whitespace-normal">
{empty}
</TableCell>
</TableRow>
) : null}
{rows.map((row) => (
<TableRow
key={rowKey(row)}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{columns.map((column) => (
<TableCell
key={column.id}
className={
typeof column.cellClassName === 'function'
? column.cellClassName(row)
: column.cellClassName
}
>
{column.cell(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)

View file

@ -2,7 +2,7 @@ import { CircleAlertIcon } from 'lucide-react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
import { municipalityListCoverageLabels } from '@/utilities/municipalityUi'
import { municipalityListCoverageLabels } from '@/utilities/municipalityLabels'
export const campaignUserInitials = (name: string): string =>
name

View file

@ -6,7 +6,7 @@ import {
formatMunicipalityVoteShare,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityUi'
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityLabels'
import type { MunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
const seriesVotes = (baseline: MunicipalityElectoralBaseline, year: number): number =>

View file

@ -23,7 +23,7 @@ import {
municipalityPriorityLabels,
politicalTrendBadgeVariant,
politicalTrendLabels,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityLabels'
import type {
MunicipalityAdvisorSummary,
MunicipalityDetailViewModel,

View file

@ -11,22 +11,25 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
applyMunicipalityKindFilter,
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
clearMunicipalityListFilters,
formatMunicipalityActiveFiltersSummary,
getMunicipalityFilterDefinition,
getMunicipalitySingleFilterValue,
municipalityFilterDefinitions,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
type MunicipalityFilterOption,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
municipalityListSortOptions,
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
type MunicipalityFilterOption,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListUrl'
const SEARCH_DEBOUNCE_MS = 1000
@ -200,7 +203,7 @@ export const MunicipalityFilters = ({
setSearch('')
startTransition(() => {
router.replace(
buildMunicipalityListHref({ page: 1, sort: state.sort, dir: state.dir }, 1),
buildMunicipalityListHref(clearMunicipalityListFilters(state), 1),
{ scroll: false },
)
})

View file

@ -1,17 +1,18 @@
'use client'
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
import { useMemo, useState, type ReactNode } from 'react'
import { useMemo, useOptimistic, useState, type ReactNode } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { Checkbox } from '@/components/ui/Checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { cn } from '@/lib/utils'
import { municipalityPriorityLabels } from '@/utilities/municipalityLabels'
import {
applyMunicipalityKindFilter,
buildMunicipalityFilterHref,
buildMunicipalityFiltersKey,
buildMunicipalityFilterOptionHref,
clearMunicipalityAdvisorFilters,
clearMunicipalityMultiFilter,
clearMunicipalityNameFilters,
@ -19,22 +20,26 @@ import {
getMunicipalityMultiFilterValues,
getMunicipalitySingleFilterValue,
isMunicipalityColumnFilterActive,
municipalityPriorityLabels,
municipalityFilterOptionsForSlugs,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
toggleMunicipalityPriorityFilter,
type MunicipalityFilterOption,
type MunicipalityFilterParam,
type MunicipalityListState,
type MunicipalityMultiFilterParam,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListFilters'
import { type MunicipalityListState } from '@/utilities/municipalityListUrl'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
type MunicipalityHeaderFilterProps = {
state: MunicipalityListState
filterParam: MunicipalityFilterParam
/** Options already narrowed by the other active filters (server-computed). */
options?: MunicipalityFilterOption[]
/**
* Options already narrowed by the other active filters (server-computed).
* The Município column ships bare slugs — labels come from the catalog here
* on the client, so the RSC payload never carries 435 name pairs (B16+).
*/
options?: MunicipalityFilterOption[] | readonly string[]
/** Staff-only Prioritária checkbox inside the Município filter. */
showPriorityFilter?: boolean
}
@ -94,29 +99,27 @@ const FilterOptionLink = ({
export const MunicipalityHeaderFilter = ({
state,
filterParam,
options: optionsOverride,
options: optionsInput,
showPriorityFilter = false,
}: MunicipalityHeaderFilterProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
/**
* Optimistic selection, tagged with the URL state it was derived from: when the
* server answer lands the tag stops matching and the truth takes over — no
* effect racing the transition (and no reverting a still-pending click).
* Optimistic selection: set inside the navigation transition (see
* `CampaignTransitionAnchor.onNavigate`), so React shows the clicked state
* immediately and discards it for the server truth when the transition
* settles — no manual base-key bookkeeping, no reverting a pending click.
*/
const [optimistic, setOptimistic] = useState<{
baseKey: string
next: MunicipalityListState
} | null>(null)
const [viewState, setOptimisticState] = useOptimistic(state)
const definition = getMunicipalityFilterDefinition(filterParam)
const definitionOptions = definition.options
const options = useMemo(
() => optionsOverride ?? definitionOptions ?? [],
[optionsOverride, definitionOptions],
)
const stateKey = buildMunicipalityFiltersKey(state)
const viewState = optimistic?.baseKey === stateKey ? optimistic.next : state
const options = useMemo(() => {
if (!optionsInput) return definitionOptions ?? []
return typeof optionsInput[0] === 'string' || optionsInput.length === 0
? municipalityFilterOptionsForSlugs(optionsInput as readonly string[])
: (optionsInput as MunicipalityFilterOption[])
}, [optionsInput, definitionOptions])
const isActive = isMunicipalityColumnFilterActive(viewState, filterParam)
const searchable = options.length >= SEARCHABLE_OPTION_THRESHOLD
@ -131,7 +134,7 @@ export const MunicipalityHeaderFilter = ({
: options
const commit = (next: MunicipalityListState) => {
setOptimistic({ baseKey: stateKey, next })
setOptimisticState(next)
if (definition.selection !== 'multi') setOpen(false)
}
@ -282,21 +285,22 @@ export const MunicipalityHeaderFilter = ({
})
: null}
{/* Option hrefs ride the cheap canonical-toggle serializer; the full
toggle (state for the optimistic commit) runs only on click. */}
{multiParam
? visibleOptions.map((option) => {
const next = toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value)
return (
<FilterOptionLink
key={option.value}
href={buildMunicipalityFilterHref(next)}
selected={selectedMulti.includes(option.value)}
checkbox
onChoose={() => commit(next)}
>
{option.label}
</FilterOptionLink>
)
})
? visibleOptions.map((option) => (
<FilterOptionLink
key={option.value}
href={buildMunicipalityFilterOptionHref(viewState, multiParam, option.value)}
selected={selectedMulti.includes(option.value)}
checkbox
onChoose={() =>
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value))
}
>
{option.label}
</FilterOptionLink>
))
: null}
{visibleOptions.length === 0 ? (

View file

@ -2,6 +2,7 @@ import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/CampaignTable'
import {
MissingAdvisorBadge,
MunicipalityAdvisorAvatarStack,
@ -21,33 +22,32 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatElectionNumber } from '@/lib/electionInsights'
import { formatMunicipalityVoteRank, formatMunicipalityVoteShare } from '@/lib/municipalityVoteRank'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
buildMunicipalityListHref,
formatMunicipalityConcentrationHint,
formatMunicipalityGeographyLabel,
municipalityKindLabels,
municipalityPriorityLabels,
} from '@/utilities/municipalityLabels'
import {
clearMunicipalityListFilters,
type MunicipalityFilterOption,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityListHref,
formatMunicipalityListSortSummary,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalityKindLabels,
municipalityPriorityLabels,
municipalitySignalAgeInDays,
resolveMunicipalityListSort,
type MunicipalityFilterOption,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalitySignal'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
@ -67,7 +67,8 @@ type MunicipalityStaffFormAction = (
) => Promise<CampaignFormActionState>
type MunicipalityColumnFilterOptions = {
name: MunicipalityFilterOption[]
/** Bare catalog slugs — labeled on the client (B16+ payload trim). */
name: readonly string[]
region: MunicipalityFilterOption[]
advisor: MunicipalityFilterOption[]
}
@ -174,7 +175,7 @@ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState })
<EmptyContent>
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
<CampaignTransitionAnchor
href={buildMunicipalityListHref({ page: 1, sort: state.sort, dir: state.dir }, 1)}
href={buildMunicipalityListHref(clearMunicipalityListFilters(state), 1)}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
@ -199,17 +200,214 @@ const advisorNames = (
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
export const MunicipalityList = ({
municipalities,
advisorNamesById,
/** The desktop table as column definitions (Pass 2 W1 list system). */
const municipalityListColumns = ({
state,
isStaffView,
isCoordinator,
advisorOptions,
columnFilterOptions,
advisorNamesById,
advisorOptions,
trendFormAction,
advisorsFormAction,
state,
}: MunicipalityListProps) => {
concentrationHint,
signalHint,
deficitHint,
}: MunicipalityListProps & {
concentrationHint: string
signalHint: string
deficitHint: string
}): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
{
id: 'name',
mandatory: true,
head: (
<MunicipalitySortableHead
state={state}
sortKey="name"
filterParam="name"
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
>
Município
</MunicipalitySortableHead>
),
cellClassName: 'max-w-52 whitespace-normal',
cell: (municipality) => (
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{municipality.name}
</Link>
{municipality.priority === 'alta' && isStaffView ? (
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
) : null}
</div>
),
},
{
id: 'region',
head: (
<MunicipalitySortableHead
state={state}
sortKey="region"
filterParam="region"
filterOptions={columnFilterOptions.region}
>
Território
</MunicipalitySortableHead>
),
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (municipality) => municipality.region,
},
{
id: 'kind',
head: (
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
Tipo
</MunicipalitySortableHead>
),
cell: (municipality) => municipalityKindLabels[municipality.kind],
},
{
id: 'votos',
head: (
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
tooltip={concentrationHint}
/>
),
cellClassName: 'text-right',
cell: (municipality) =>
municipality.votePosition2022 ? (
<VotePositionReadout position={municipality.votePosition2022} layout="table" />
) : (
<span className="text-muted-foreground">—</span>
),
},
...(isStaffView
? ([
{
id: 'advisors',
head: (
<MunicipalitySortableHead
state={state}
sortKey="coverage"
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
>
Assessores
</MunicipalitySortableHead>
),
cell: (municipality) =>
isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={municipality.priority === 'alta'}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : (
<MunicipalityAdvisorAvatarStack
advisors={advisorEntries(municipality, advisorNamesById)}
isPriority={municipality.priority === 'alta'}
/>
),
},
{
id: 'trend',
head: (
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
Tendência
</MunicipalitySortableHead>
),
cell: (municipality) => (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
formAction={trendFormAction}
/>
),
},
{
id: 'expectedVotes',
head: (
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
Votos estimados
</MunicipalitySortableHead>
),
cellClassName: 'relative overflow-visible align-middle text-center',
cell: (municipality) => (
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
/>
</div>
),
},
{
id: 'lastSignal',
head: (
<MunicipalitySortableHead state={state} sortKey="frescor" tooltip={signalHint}>
Último sinal
</MunicipalitySortableHead>
),
cell: (municipality) => (
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
),
},
{
id: 'goalCoverage',
head: (
<MunicipalitySortableHead state={state} sortKey="deficit" tooltip={deficitHint}>
Cobertura da meta
</MunicipalitySortableHead>
),
cell: (municipality) => (
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
layout="compact"
/>
),
},
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)
: ([
{
id: 'lastUpdateAt',
head: (
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
Última atualização
</MunicipalitySortableHead>
),
cell: (municipality) =>
municipality.lastUpdateAt
? dateFormatter.format(new Date(municipality.lastUpdateAt))
: 'Sem atualização',
},
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)),
]
export const MunicipalityList = (props: MunicipalityListProps) => {
const {
municipalities,
advisorNamesById,
isStaffView,
isCoordinator,
advisorOptions,
trendFormAction,
state,
} = props
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const concentrationHint = formatMunicipalityConcentrationHint()
@ -217,8 +415,8 @@ export const MunicipalityList = ({
// The scenario picker is client state, so the server can only order by one
// scenario — named here so the ordering never looks arbitrary.
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
// Keep in sync with the header row below.
const columnCount = isStaffView ? 9 : 5
const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint })
return (
<>
@ -295,7 +493,7 @@ export const MunicipalityList = ({
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
formAction={props.advisorsFormAction}
/>
) : names.length ? (
names.join(', ')
@ -317,180 +515,20 @@ export const MunicipalityList = ({
{/* No inner scroller: the app shell's <main> scrolls, so the sticky header
resolves against it. A sticky <th> can't paint the row border, hence the
inset shadow standing in for it. */}
<div data-view="desktop-table" className="hidden rounded-xl border md:block">
<Table containerClassName="overflow-x-visible">
<TableCaption className="sr-only">
<CampaignTable
className="hidden overflow-visible md:block"
containerClassName="overflow-x-visible"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}. Coluna 2022: {concentrationHint}
</TableCaption>
<TableHeader className="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0">
<TableRow>
<MunicipalitySortableHead
state={state}
sortKey="name"
filterParam="name"
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
>
Município
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="region"
filterParam="region"
filterOptions={columnFilterOptions.region}
>
Território
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
Tipo
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
tooltip={concentrationHint}
/>
{isStaffView ? (
<>
<MunicipalitySortableHead
state={state}
sortKey="coverage"
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
>
Assessores
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
Tendência
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
Votos estimados
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="frescor"
tooltip={signalHint}
>
Último sinal
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="deficit"
tooltip={deficitHint}
>
Cobertura da meta
</MunicipalitySortableHead>
</>
) : (
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
Última atualização
</MunicipalitySortableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{municipalities.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columnCount} className="whitespace-normal">
<MunicipalityListEmptyState state={state} />
</TableCell>
</TableRow>
) : null}
{municipalities.map((municipality) => {
const isPriority = municipality.priority === 'alta'
return (
<TableRow key={municipality.id}>
<TableCell className="max-w-52 whitespace-normal">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{municipality.name}
</Link>
{isPriority && isStaffView ? (
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
) : null}
</div>
</TableCell>
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
{municipality.region}
</TableCell>
<TableCell>{municipalityKindLabels[municipality.kind]}</TableCell>
<TableCell className="text-right">
{municipality.votePosition2022 ? (
<VotePositionReadout
position={municipality.votePosition2022}
layout="table"
/>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
{isStaffView ? (
<>
<TableCell>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : (
<MunicipalityAdvisorAvatarStack
advisors={advisorEntries(municipality, advisorNamesById)}
isPriority={isPriority}
/>
)}
</TableCell>
<TableCell>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
formAction={trendFormAction}
/>
</TableCell>
<TableCell className="relative overflow-visible align-middle text-center">
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
/>
</div>
</TableCell>
<TableCell>
<SignalAgeReadout
lastSignalAt={municipality.lastSignalAt}
layout="table"
/>
</TableCell>
<TableCell>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
layout="compact"
/>
</TableCell>
</>
) : (
<TableCell>
{municipality.lastUpdateAt
? dateFormatter.format(new Date(municipality.lastUpdateAt))
: 'Sem atualização'}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
}
columns={columns}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
</>
)
}

View file

@ -11,7 +11,7 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/municipalityUi'
import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/municipalityLabels'
import type { MunicipalityPoliticalTrendViewModel } from '@/utilities/municipalityViewModels'
type MunicipalityListTrendControlProps = {

View file

@ -8,16 +8,18 @@ import { MunicipalityHeaderFilter } from '@/components/campaign/MunicipalityHead
import { MunicipalityHoverTooltip } from '@/components/campaign/MunicipalityHoverTooltip'
import { TableHead } from '@/components/ui/Table'
import { cn } from '@/lib/utils'
import type {
MunicipalityFilterOption,
MunicipalityFilterParam,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalitySortHref,
defaultMunicipalityListSortDir,
municipalityListSortLabels,
resolveMunicipalityListSort,
type MunicipalityFilterOption,
type MunicipalityFilterParam,
type MunicipalityListSortKey,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListUrl'
type MunicipalitySortableHeadProps = {
state: MunicipalityListState
@ -27,7 +29,8 @@ type MunicipalitySortableHeadProps = {
className?: string
align?: 'left' | 'center' | 'right'
filterParam?: MunicipalityFilterParam
filterOptions?: MunicipalityFilterOption[]
/** Pairs, or bare catalog slugs labeled on the client (B16+ payload trim). */
filterOptions?: MunicipalityFilterOption[] | readonly string[]
showPriorityFilter?: boolean
}

View file

@ -8,7 +8,7 @@ import {
municipalityPriorityLabels,
politicalTrendBadgeVariant,
politicalTrendLabels,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityLabels'
import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')

View file

@ -13,7 +13,7 @@ import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import { politicalTrendLabels } from '@/utilities/municipalityUi'
import { politicalTrendLabels } from '@/utilities/municipalityLabels'
import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels'
type FormAction = (

View file

@ -0,0 +1,67 @@
/**
* Municipality display labels shared by list, detail, dossier and map
* surfaces. Client-safe: types + string tables only (split out of the former
* `municipalityUi.ts` in Pass 2 W1).
*/
import { formatElectionNumber } from '@/lib/electionInsights'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
municipio: 'Município',
zona: 'Zona eleitoral',
}
export const municipalityPriorityLabels: Record<NonNullable<Municipality['priority']>, string> = {
alta: 'Prioritária',
normal: 'Normal',
}
export type PoliticalTrendStatus = NonNullable<
NonNullable<Municipality['politicalTrend']>['status']
>
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
favoravel: 'Favorável',
neutra: 'Neutra',
desfavoravel: 'Desfavorável',
}
export const politicalTrendBadgeVariant = {
favoravel: 'estimate-confirmed',
neutra: 'secondary',
desfavoravel: 'destructive',
} as const
export const municipalityListCoverageLabels: Record<'com_assessor' | 'sem_assessor', string> = {
com_assessor: 'Com assessor',
sem_assessor: 'Sem assessor',
}
export const formatMunicipalityConcentrationHint = (
totalUnits: number = municipalityCatalog.length,
): string =>
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
export const getCampaignScopeLabel = (
role: CampaignUser['role'],
municipalityCount: number,
): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
if (role === 'leader') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
}
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
export const formatMunicipalityGeographyLabel = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): string =>
municipality.kind === 'zona' && municipality.zoneNumber != null
? `${municipality.region} · ZE ${municipality.zoneNumber}`
: municipality.region

View file

@ -0,0 +1,324 @@
/**
* Municipality list filter affordances (B16 header popovers + mobile bar):
* definitions, selection helpers and the active-filters summary. Split out of
* the former `municipalityUi.ts` in Pass 2 W1.
*/
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import {
buildMunicipalityListHref,
municipalityListStateToRawParams,
parseMunicipalityListParams,
serializeCanonicalMunicipalityListSearchParams,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import {
municipalityKindLabels,
municipalityListCoverageLabels,
municipalityPriorityLabels,
politicalTrendLabels,
type PoliticalTrendStatus,
} from '@/utilities/municipalityLabels'
/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
export type MunicipalityFilterOption = {
value: string
label: string
}
/** Catalog labels for a (possibly cross-filtered) slug list. */
export const municipalityFilterOptionsForSlugs = (
slugs: readonly string[],
): MunicipalityFilterOption[] =>
slugs.map((slug) => ({ value: slug, label: getMunicipalityCatalogEntry(slug)?.name ?? slug }))
export type MunicipalityFilterDefinition = {
param: MunicipalityFilterParam
label: string
/** Shown for exclusive single-select clear row; omitted for toggle/multi. */
allLabel?: string
staffOnly: boolean
selection: MunicipalityFilterSelectionMode
/** Absent for the params whose options are cross-filtered server-side. */
options?: MunicipalityFilterOption[]
}
export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
{
param: 'name',
label: 'Município',
staffOnly: false,
selection: 'multi',
},
{
param: 'region',
label: 'Território',
staffOnly: false,
selection: 'multi',
},
{
param: 'kind',
label: 'Tipo',
allLabel: 'Todos',
staffOnly: false,
selection: 'single',
options: (
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
},
{
param: 'advisor',
label: 'Assessores',
staffOnly: true,
selection: 'multi',
},
{
param: 'coverage',
label: 'Assessoria',
staffOnly: true,
selection: 'toggle',
options: (
Object.keys(municipalityListCoverageLabels) as Array<
keyof typeof municipalityListCoverageLabels
>
).map((coverage) => ({ value: coverage, label: municipalityListCoverageLabels[coverage] })),
},
{
param: 'trend',
label: 'Tendência',
staffOnly: true,
selection: 'multi',
options: (Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
),
},
]
const municipalityFilterDefinitionByParam = Object.fromEntries(
municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
) as Record<MunicipalityFilterParam, MunicipalityFilterDefinition>
export const getMunicipalityFilterDefinition = (
param: MunicipalityFilterParam,
): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
export const getMunicipalitySingleFilterValue = (
state: MunicipalityListState,
param: 'kind' | 'coverage',
): string | undefined => state[param]
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
export const getMunicipalityMultiFilterValues = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
): string[] => {
if (param === 'region') return state.regions ?? []
if (param === 'slug') return state.slugs ?? []
if (param === 'trend') return state.trends ?? []
return (state.advisors ?? []).map(String)
}
const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
export const applyMunicipalityKindFilter = (
state: MunicipalityListState,
value: string | undefined,
): MunicipalityListState =>
parseMunicipalityListParams({
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
kind: value || undefined,
})
/** Toggle exclusivity: clicking the active value clears (coverage). */
export const toggleMunicipalityExclusiveFilterValue = (
state: MunicipalityListState,
param: 'coverage',
value: string,
): MunicipalityListState =>
withMunicipalityListPageReset({
...state,
[param]: state[param] === value ? undefined : (value as MunicipalityListState['coverage']),
})
export const toggleMunicipalityPriorityFilter = (
state: MunicipalityListState,
): MunicipalityListState =>
withMunicipalityListPageReset({
...state,
priority: state.priority === 'alta' ? undefined : 'alta',
})
/**
* Writes the RAW param and lets `parseMunicipalityListParams` validate, so no
* branch has to cast a `string[]` into the state's narrow value types.
*/
const setMunicipalityMultiFilterValues = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
values: string[],
): MunicipalityListState =>
parseMunicipalityListParams({
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
[param]: values,
})
export const toggleMunicipalityMultiFilterValue = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
value: string,
): MunicipalityListState => {
const current = getMunicipalityMultiFilterValues(state, param)
return setMunicipalityMultiFilterValues(
state,
param,
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
)
}
export const clearMunicipalityMultiFilter = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
): MunicipalityListState => setMunicipalityMultiFilterValues(state, param, [])
/** Clear municipality-column filters (priority + selected slugs). */
export const clearMunicipalityNameFilters = (state: MunicipalityListState): MunicipalityListState =>
withMunicipalityListPageReset({ ...state, priority: undefined, slugs: undefined })
/** Clear advisor-column filters (com/sem assessor + selected advisors). */
export const clearMunicipalityAdvisorFilters = (
state: MunicipalityListState,
): MunicipalityListState =>
withMunicipalityListPageReset({ ...state, coverage: undefined, advisors: undefined })
/**
* The "Limpar" contract shared by the filter bar and the empty state: drop
* every filter and the search, keep the sort.
*/
export const clearMunicipalityListFilters = (
state: MunicipalityListState,
): MunicipalityListState => ({ page: 1, sort: state.sort, dir: state.dir })
export const buildMunicipalityFilterHref = (next: MunicipalityListState): string =>
buildMunicipalityListHref(next, 1)
const trendStatusCount = Object.keys(politicalTrendLabels).length
const isPoliticalTrendStatus = (value: string): value is PoliticalTrendStatus =>
value in politicalTrendLabels
/**
* B16+ href fast path: option rows only need the WOULD-BE href, so the toggled
* state is derived with the same canonical rules the parser applies (advisors
* numerically sorted, the full trend set collapsing to "todas" = absent)
* instead of a full parse round-trip per rendered option — the popover renders
* up to 435 of these per open.
*/
export const buildMunicipalityFilterOptionHref = (
canonicalState: MunicipalityListState,
param: MunicipalityMultiFilterParam,
value: string,
): string => {
const current = getMunicipalityMultiFilterValues(canonicalState, param)
const toggled = current.includes(value)
? current.filter((entry) => entry !== value)
: [...current, value]
const next: MunicipalityListState = { ...canonicalState, page: 1 }
if (param === 'region') {
const regions = toggled.filter(isBahiaIdentityTerritory)
if (regions.length) next.regions = regions
else delete next.regions
} else if (param === 'slug') {
const slugs = toggled.filter((slug) => getMunicipalityCatalogEntry(slug) !== undefined)
if (slugs.length) next.slugs = slugs
else delete next.slugs
} else if (param === 'advisor') {
const advisors = toggled
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0)
.sort((left, right) => left - right)
if (advisors.length) next.advisors = advisors
else delete next.advisors
} else {
const trends = toggled.filter(isPoliticalTrendStatus)
if (trends.length && trends.length < trendStatusCount) next.trends = trends
else delete next.trends
}
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
return query ? `/campanha/municipios?${query}` : '/campanha/municipios'
}
export const isMunicipalityColumnFilterActive = (
state: MunicipalityListState,
param: MunicipalityFilterParam,
): boolean => {
switch (param) {
case 'name':
return Boolean(state.priority || state.slugs?.length)
case 'region':
return Boolean(state.regions?.length)
case 'advisor':
// The Assessores popover also owns the com/sem assessor toggle.
return Boolean(state.advisors?.length || state.coverage)
case 'kind':
return Boolean(state.kind)
case 'coverage':
return Boolean(state.coverage)
case 'trend':
return Boolean(state.trends?.length)
}
}
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
const firstNamesLabel = (names: string[]): string =>
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
export const formatMunicipalityActiveFiltersSummary = (
state: MunicipalityListState,
): string | null => {
const parts: string[] = []
if (state.priority) parts.push(municipalityPriorityLabels.alta)
if (state.slugs?.length) {
parts.push(
firstNamesLabel(
state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug),
),
)
}
if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
if (state.kind) parts.push(municipalityKindLabels[state.kind])
if (state.advisors?.length) {
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
}
if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
if (state.trends?.length) {
parts.push(
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
)
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
const summary = formatMunicipalityActiveFiltersSummary(state)
if (!summary) return null
const label = `Municípios · ${summary}`
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
buildMunicipalityListHref(state, 1)

View file

@ -1,9 +1,13 @@
/**
* Municipality list URL contract: state type, param parsing/canonicalization,
* Payload `where`, serialization and sort. Split out of the former
* `municipalityUi.ts` in Pass 2 W1. The URL contract is frozen — B18 (saved
* filters) depends on it.
*/
import type { Where } from 'payload'
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { formatElectionNumber } from '@/lib/electionInsights'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import {
allParamValues,
buildListHref,
@ -13,37 +17,13 @@ import {
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import { latestIsoTimestamp } from '@/utilities/campaignTime'
import { politicalTrendLabels, type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
import type { Municipality } from '@/payload-types'
export const municipalityPageSize = 25
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
municipio: 'Município',
zona: 'Zona eleitoral',
}
export const municipalityPriorityLabels: Record<NonNullable<Municipality['priority']>, string> = {
alta: 'Prioritária',
normal: 'Normal',
}
export type PoliticalTrendStatus = NonNullable<
NonNullable<Municipality['politicalTrend']>['status']
>
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
favoravel: 'Favorável',
neutra: 'Neutra',
desfavoravel: 'Desfavorável',
}
export const politicalTrendBadgeVariant = {
favoravel: 'estimate-confirmed',
neutra: 'secondary',
desfavoravel: 'destructive',
} as const
export type MunicipalityListSortKey =
| 'name'
| 'region'
@ -65,8 +45,7 @@ export type MunicipalityListSortDirection = 'asc' | 'desc'
* the candidate's own 2022 vote, this ordering stays close to the previous
* `votos` default instead of surfacing deserts with inflated goals.
*/
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'desc'
const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
name: 'Município',
@ -108,7 +87,7 @@ export type MunicipalityListState = {
export type MunicipalityListSearchParams = CampaignListRawSearchParams
export const municipalityListParamNames = [
const municipalityListParamNames = [
'q',
'region',
'slug',
@ -123,11 +102,6 @@ export const municipalityListParamNames = [
'page',
] as const
const municipalitySlugSet = new Set(municipalityCatalog.map((entry) => entry.slug))
const municipalityNameBySlug = new Map(
municipalityCatalog.map((entry) => [entry.slug, entry.name] as const),
)
const municipalityListParamNameSet = new Set<string>(municipalityListParamNames)
// Derived from the label record (total over the union), so a new sort key is
@ -157,55 +131,6 @@ export const resolveMunicipalityListSort = (
return { sort, dir }
}
export const formatMunicipalityConcentrationHint = (
totalUnits: number = municipalityCatalog.length,
): string =>
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
/**
* E9 frescor — the last time ANYBODY recorded something here: a staff update
* or a leadership pledge declaration/estimate. One rule, shared by the server
* ordering (`municipalityPageData.ts`) and the list cell, so "há N dias"
* never disagrees with the position in the queue.
*/
export const resolveMunicipalityLastSignalAt = (
lastUpdateAt: string | null,
lastPledgeAt: string | null,
): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
/**
* Days since the last signal, floored. The research report only says a
* commitment left untouched "for weeks" is worth less, so the threshold below
* is a convention (3 weeks), not a measured decay curve.
*/
export const MUNICIPALITY_COLD_SIGNAL_DAYS = 21
export const municipalitySignalAgeInDays = (
lastSignalAt: string | null,
now: Date = new Date(),
): number | null => {
if (!lastSignalAt) return null
const elapsed = now.getTime() - new Date(lastSignalAt).getTime()
if (Number.isNaN(elapsed)) return null
return Math.max(0, Math.floor(elapsed / 86_400_000))
}
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
/**
* "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
* Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
* "anteontem" and minute/hour granularity, which breaks both the day-based
* cold threshold and the tabular-nums scan down the column.
*/
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
if (ageInDays === null) return 'Sem sinal'
if (ageInDays === 0) return 'hoje'
if (ageInDays === 1) return 'há 1 dia'
return `há ${ageInDays} dias`
}
export const formatMunicipalityListSortSummary = (
sort: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
@ -250,7 +175,7 @@ const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
const slugs: string[] = []
const seen = new Set<string>()
for (const token of allParamValues(raw)) {
if (!municipalitySlugSet.has(token) || seen.has(token)) continue
if (!isMunicipalitySlug(token) || seen.has(token)) continue
seen.add(token)
slugs.push(token)
}
@ -283,7 +208,7 @@ const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendSta
return trends.length < politicalTrendStatusSet.size ? trends : []
}
const municipalityListStateToRawParams = (
export const municipalityListStateToRawParams = (
state: MunicipalityListState,
page = state.page,
): MunicipalityListSearchParams => ({
@ -363,11 +288,15 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
return filters.length ? { and: filters } : {}
}
export const buildMunicipalityListSearchParams = (
state: MunicipalityListState,
page = state.page,
/**
* Serializes a state that is ALREADY canonical (came out of
* `parseMunicipalityListParams`, or was derived from a canonical state by a
* rule-preserving toggle). Kept private-ish so the public builder below stays
* the only entry point that accepts arbitrary states.
*/
export const serializeCanonicalMunicipalityListSearchParams = (
canonicalState: MunicipalityListState,
): URLSearchParams => {
const canonicalState = parseMunicipalityListParams(municipalityListStateToRawParams(state, page))
const params = new URLSearchParams()
const resolvedSort = canonicalState.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const resolvedDir = canonicalState.dir ?? defaultMunicipalityListSortDir(resolvedSort)
@ -397,6 +326,14 @@ export const buildMunicipalityListSearchParams = (
return params
}
const buildMunicipalityListSearchParams = (
state: MunicipalityListState,
page = state.page,
): URLSearchParams =>
serializeCanonicalMunicipalityListSearchParams(
parseMunicipalityListParams(municipalityListStateToRawParams(state, page)),
)
export const buildMunicipalityFiltersKey = (state: MunicipalityListState): string =>
buildMunicipalityListSearchParams(state).toString()
@ -427,7 +364,7 @@ export const buildMunicipalitySortHref = (
return buildMunicipalityListHref(updatedState, 1)
}
export const formatMunicipalitySortOptionLabel = (
const formatMunicipalitySortOptionLabel = (
key: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): string => {
@ -489,277 +426,3 @@ export const resolveMunicipalityListUrl = (
basePath: '/campanha/municipios',
totalPages,
})
export const municipalityListCoverageLabels: Record<
NonNullable<MunicipalityListState['coverage']>,
string
> = {
com_assessor: 'Com assessor',
sem_assessor: 'Sem assessor',
}
/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
export type MunicipalityFilterOption = {
value: string
label: string
}
/** Catalog labels for a (possibly cross-filtered) slug list. */
export const municipalityFilterOptionsForSlugs = (
slugs: readonly string[],
): MunicipalityFilterOption[] =>
slugs.map((slug) => ({ value: slug, label: municipalityNameBySlug.get(slug) ?? slug }))
export type MunicipalityFilterDefinition = {
param: MunicipalityFilterParam
label: string
/** Shown for exclusive single-select clear row; omitted for toggle/multi. */
allLabel?: string
staffOnly: boolean
selection: MunicipalityFilterSelectionMode
/** Absent for the params whose options are cross-filtered server-side. */
options?: MunicipalityFilterOption[]
}
export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
{
param: 'name',
label: 'Município',
staffOnly: false,
selection: 'multi',
},
{
param: 'region',
label: 'Território',
staffOnly: false,
selection: 'multi',
},
{
param: 'kind',
label: 'Tipo',
allLabel: 'Todos',
staffOnly: false,
selection: 'single',
options: (
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
},
{
param: 'advisor',
label: 'Assessores',
staffOnly: true,
selection: 'multi',
},
{
param: 'coverage',
label: 'Assessoria',
staffOnly: true,
selection: 'toggle',
options: (
Object.keys(municipalityListCoverageLabels) as Array<
keyof typeof municipalityListCoverageLabels
>
).map((coverage) => ({ value: coverage, label: municipalityListCoverageLabels[coverage] })),
},
{
param: 'trend',
label: 'Tendência',
staffOnly: true,
selection: 'multi',
options: (Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
),
},
]
const municipalityFilterDefinitionByParam = Object.fromEntries(
municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
) as Record<MunicipalityFilterParam, MunicipalityFilterDefinition>
export const getMunicipalityFilterDefinition = (
param: MunicipalityFilterParam,
): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
export const getMunicipalitySingleFilterValue = (
state: MunicipalityListState,
param: 'kind' | 'coverage',
): string | undefined => state[param]
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
export const getMunicipalityMultiFilterValues = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
): string[] => {
if (param === 'region') return state.regions ?? []
if (param === 'slug') return state.slugs ?? []
if (param === 'trend') return state.trends ?? []
return (state.advisors ?? []).map(String)
}
const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
export const applyMunicipalityKindFilter = (
state: MunicipalityListState,
value: string | undefined,
): MunicipalityListState =>
parseMunicipalityListParams({
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
kind: value || undefined,
})
/** Toggle exclusivity: clicking the active value clears (coverage). */
export const toggleMunicipalityExclusiveFilterValue = (
state: MunicipalityListState,
param: 'coverage',
value: string,
): MunicipalityListState =>
withMunicipalityListPageReset({
...state,
[param]: state[param] === value ? undefined : (value as MunicipalityListState['coverage']),
})
export const toggleMunicipalityPriorityFilter = (
state: MunicipalityListState,
): MunicipalityListState =>
withMunicipalityListPageReset({
...state,
priority: state.priority === 'alta' ? undefined : 'alta',
})
/**
* Writes the RAW param and lets `parseMunicipalityListParams` validate, so no
* branch has to cast a `string[]` into the state's narrow value types.
*/
const setMunicipalityMultiFilterValues = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
values: string[],
): MunicipalityListState =>
parseMunicipalityListParams({
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
[param]: values,
})
export const toggleMunicipalityMultiFilterValue = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
value: string,
): MunicipalityListState => {
const current = getMunicipalityMultiFilterValues(state, param)
return setMunicipalityMultiFilterValues(
state,
param,
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
)
}
export const clearMunicipalityMultiFilter = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
): MunicipalityListState => setMunicipalityMultiFilterValues(state, param, [])
/** Clear municipality-column filters (priority + selected slugs). */
export const clearMunicipalityNameFilters = (state: MunicipalityListState): MunicipalityListState =>
withMunicipalityListPageReset({ ...state, priority: undefined, slugs: undefined })
/** Clear advisor-column filters (com/sem assessor + selected advisors). */
export const clearMunicipalityAdvisorFilters = (
state: MunicipalityListState,
): MunicipalityListState =>
withMunicipalityListPageReset({ ...state, coverage: undefined, advisors: undefined })
export const buildMunicipalityFilterHref = (next: MunicipalityListState): string =>
buildMunicipalityListHref(next, 1)
export const isMunicipalityColumnFilterActive = (
state: MunicipalityListState,
param: MunicipalityFilterParam,
): boolean => {
switch (param) {
case 'name':
return Boolean(state.priority || state.slugs?.length)
case 'region':
return Boolean(state.regions?.length)
case 'advisor':
// The Assessores popover also owns the com/sem assessor toggle.
return Boolean(state.advisors?.length || state.coverage)
case 'kind':
return Boolean(state.kind)
case 'coverage':
return Boolean(state.coverage)
case 'trend':
return Boolean(state.trends?.length)
}
}
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
const firstNamesLabel = (names: string[]): string =>
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
export const formatMunicipalityActiveFiltersSummary = (
state: MunicipalityListState,
): string | null => {
const parts: string[] = []
if (state.priority) parts.push(municipalityPriorityLabels.alta)
if (state.slugs?.length) {
parts.push(firstNamesLabel(state.slugs.map((slug) => municipalityNameBySlug.get(slug) ?? slug)))
}
if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
if (state.kind) parts.push(municipalityKindLabels[state.kind])
if (state.advisors?.length) {
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
}
if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
if (state.trends?.length) {
parts.push(
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
)
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
const summary = formatMunicipalityActiveFiltersSummary(state)
if (!summary) return null
const label = `Municípios · ${summary}`
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
buildMunicipalityListHref(state, 1)
export const getCampaignScopeLabel = (
role: CampaignUser['role'],
municipalityCount: number,
): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
if (role === 'leader') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
}
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
export const formatMunicipalityGeographyLabel = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): string =>
municipality.kind === 'zona' && municipality.zoneNumber != null
? `${municipality.region} · ZE ${municipality.zoneNumber}`
: municipality.region

View file

@ -25,7 +25,7 @@ import {
parseMunicipalityListParams,
type MunicipalityListSearchParams,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListUrl'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,

View file

@ -17,13 +17,13 @@ import {
buildMunicipalityListWhere,
municipalityPageSize,
parseMunicipalityListParams,
resolveMunicipalityLastSignalAt,
resolveMunicipalityListSort,
type MunicipalityListSearchParams,
type MunicipalityListState,
type MunicipalityListSortDirection,
type MunicipalityListSortKey,
} from '@/utilities/municipalityUi'
} from '@/utilities/municipalityListUrl'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import {
municipalityListSelect,
toMunicipalityDetailViewModel,
@ -101,11 +101,12 @@ const loadMunicipalityListFilterFacets = async (
payload: Payload,
user: CampaignUser,
state: MunicipalityListState,
loadedScope: { where: Where; rows: MunicipalityFacetRow[] } | null,
/** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */
loadedScope: { where: Where; rows: Promise<MunicipalityFacetRow[]> } | null,
): Promise<MunicipalityListFilterFacets> => {
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
if (loadedScope) {
rowsByWhere.set(JSON.stringify(loadedScope.where), Promise.resolve(loadedScope.rows))
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows)
}
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
const where = buildMunicipalityListWhere({ ...state, ...omit })
@ -291,7 +292,12 @@ export const loadMunicipalityListPageBundle = async (
}),
})
const [listResult, scopeCount, staffScope] = await Promise.all([
// Request-scoped shared load (docs + pledge aggregates in one place).
const staffScopePromise = isStaff
? loadMunicipalityScope(payload, user, where)
: Promise.resolve(null)
const [listResult, scopeCount, staffScope, filterFacets] = await Promise.all([
listQuery,
payload.count({
collection: 'municipality',
@ -299,17 +305,19 @@ export const loadMunicipalityListPageBundle = async (
user,
overrideAccess: false,
}),
// Request-scoped shared load (docs + pledge aggregates in one place).
isStaff ? loadMunicipalityScope(payload, user, where) : Promise.resolve(null),
staffScopePromise,
// Joins the same round of reads: its seed key derives from `where`
// synchronously, and the scope rows arrive as a promise (B16+).
loadMunicipalityListFilterFacets(
payload,
user,
state,
isStaff
? { where, rows: staffScopePromise.then((scope) => scope?.municipalities ?? []) }
: null,
),
])
const filterFacets = await loadMunicipalityListFilterFacets(
payload,
user,
state,
staffScope ? { where, rows: staffScope.municipalities } : null,
)
let overview: MunicipalityListOverviewData | null = null
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
let goalCoverageByMunicipalityID = new Map<

View file

@ -0,0 +1,49 @@
/**
* E9 "frescor" — signal freshness for the allocation queue. One rule shared by
* the server ordering (`municipalityPageData.ts`) and the list cell, so
* "há N dias" never disagrees with the position in the queue. Split out of the
* former `municipalityUi.ts` in Pass 2 W1.
*/
import { latestIsoTimestamp } from '@/utilities/campaignTime'
/**
* The last time ANYBODY recorded something here: a staff update or a
* leadership pledge declaration/estimate.
*/
export const resolveMunicipalityLastSignalAt = (
lastUpdateAt: string | null,
lastPledgeAt: string | null,
): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
/**
* Days since the last signal, floored. The research report only says a
* commitment left untouched "for weeks" is worth less, so the threshold below
* is a convention (3 weeks), not a measured decay curve.
*/
export const MUNICIPALITY_COLD_SIGNAL_DAYS = 21
export const municipalitySignalAgeInDays = (
lastSignalAt: string | null,
now: Date = new Date(),
): number | null => {
if (!lastSignalAt) return null
const elapsed = now.getTime() - new Date(lastSignalAt).getTime()
if (Number.isNaN(elapsed)) return null
return Math.max(0, Math.floor(elapsed / 86_400_000))
}
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
/**
* "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
* Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
* "anteontem" and minute/hour granularity, which breaks both the day-based
* cold threshold and the tabular-nums scan down the column.
*/
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
if (ageInDays === null) return 'Sem sinal'
if (ageInDays === 0) return 'hoje'
if (ageInDays === 1) return 'há 1 dia'
return `há ${ageInDays} dias`
}

View file

@ -4,10 +4,8 @@ import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import type { CampaignUser, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { createEmptyGoalCoverageByScenario, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import {
resolveMunicipalityLastSignalAt,
type PoliticalTrendStatus,
} from '@/utilities/municipalityUi'
import { type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import { relationshipId } from '@/utilities/relationship'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import {

View file

@ -330,7 +330,11 @@ describe('loadMunicipalityListPageBundle', () => {
expect(conflicting.filterFacets.slugs).toContain(outsideRegion.slug)
})
it('keeps the options of filters that share a popover with the applied one', async () => {
// Two full-scope bundles back to back; since B16+ the facet reads join the
// page's main Promise.all, so under the 47-way parallel suite this test
// contends for the pool harder than any other — give it headroom beyond the
// 5s default (same convention as the ledgered e2e latency budgets).
it('keeps the options of filters that share a popover with the applied one', { timeout: 15_000 }, async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -1,31 +1,37 @@
import { describe, expect, it } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityLabels'
import {
applyMunicipalityKindFilter,
buildMunicipalityFilterHref,
buildMunicipalityListHref,
buildMunicipalityListWhere,
buildMunicipalitySortHref,
buildMunicipalityFilterOptionHref,
formatMunicipalityActiveFiltersSummary,
formatMunicipalityConcentrationHint,
formatMunicipalityListSortSummary,
formatMunicipalitySignalAgeLabel,
isMunicipalityColumnFilterActive,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalityListSortLabels,
municipalitySignalAgeInDays,
parseMunicipalityListParams,
parseMunicipalitySortValue,
resolveMunicipalityLastSignalAt,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
toggleMunicipalityPriorityFilter,
} from '@/utilities/municipalityUi'
type MunicipalityMultiFilterParam,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityListHref,
buildMunicipalityListWhere,
buildMunicipalitySortHref,
formatMunicipalityListSortSummary,
municipalityListSortLabels,
parseMunicipalityListParams,
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
} from '@/utilities/municipalityListUrl'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
resolveMunicipalityLastSignalAt,
} from '@/utilities/municipalitySignal'
describe('shouldUpdateMunicipalitySearchUrl', () => {
it('returns false when canonical q matches the current URL q', () => {
@ -280,3 +286,64 @@ describe('E9 frescor do sinal', () => {
expect(formatMunicipalitySignalAgeLabel(30)).toBe('há 30 dias')
})
})
describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
const [firstSlug, secondSlug, thirdSlug] = municipalityCatalog.map((entry) => entry.slug)
const cases: Array<{
name: string
state: Parameters<typeof buildMunicipalityFilterOptionHref>[0]
param: MunicipalityMultiFilterParam
value: string
}> = [
{ name: 'adds a slug', state: parseMunicipalityListParams({}), param: 'slug', value: firstSlug! },
{
name: 'removes a selected slug',
state: parseMunicipalityListParams({ slug: [firstSlug!, secondSlug!] }),
param: 'slug',
value: secondSlug!,
},
{
name: 'keeps other filters and resets the page',
state: parseMunicipalityListParams({
q: 'santa',
page: '3',
sort: 'votos',
slug: [thirdSlug!],
priority: 'alta',
}),
param: 'slug',
value: firstSlug!,
},
{
name: 'sorts advisor ids numerically',
state: parseMunicipalityListParams({ advisor: ['20'] }),
param: 'advisor',
value: '5',
},
{
name: 'adds a region',
state: parseMunicipalityListParams({ region: ['Chapada Diamantina'] }),
param: 'region',
value: 'Sertão do São Francisco',
},
{
name: 'collapses the full trend set to "todas"',
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
param: 'trend',
value: 'desfavoravel',
},
{
name: 'removes a selected trend',
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
param: 'trend',
value: 'neutra',
},
]
it.each(cases)('matches the canonical toggle+href path: $name', ({ state, param, value }) => {
expect(buildMunicipalityFilterOptionHref(state, param, value)).toBe(
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, param, value)),
)
})
})