diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
index ac1bcd5c..74e99cb1 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx
@@ -44,7 +44,7 @@ import {
import {
formatMunicipalityGeographyLabel,
municipalityKindLabels,
-} from '@/utilities/municipalityUi'
+} from '@/utilities/municipalityLabels'
import {
loadMunicipalityUpdatesFeed,
parseMunicipalityUpdateFeedParams,
diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
index 2368c042..1ec5d89b 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
@@ -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}
/>
-
-
- {totalDocs} {totalDocs === 1 ? 'município encontrado' : 'municípios encontrados'}
-
-
buildMunicipalityListHref(state, page)}
- />
-
+ buildMunicipalityListHref(state, page)}
+ />
>
)
diff --git a/src/components/campaign/CampaignListFooter.tsx b/src/components/campaign/CampaignListFooter.tsx
new file mode 100644
index 00000000..16c5d9d9
--- /dev/null
+++ b/src/components/campaign/CampaignListFooter.tsx
@@ -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
+}) => (
+
+
+ {totalDocs} {totalDocs === 1 ? singular : plural}
+
+
+
+)
diff --git a/src/components/campaign/CampaignListPending.tsx b/src/components/campaign/CampaignListPending.tsx
index c974d310..516c1bcf 100644
--- a/src/components/campaign/CampaignListPending.tsx
+++ b/src/components/campaign/CampaignListPending.tsx
@@ -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 })
})
diff --git a/src/components/campaign/CampaignTable.tsx b/src/components/campaign/CampaignTable.tsx
new file mode 100644
index 00000000..f0a4fe79
--- /dev/null
+++ b/src/components/campaign/CampaignTable.tsx
@@ -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 = {
+ /** Stable identifier — the future column-picker key (B17). */
+ id: string
+ /**
+ * Full `` node. Plain labels use ``; 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
+}) => (
+
+ {children}
+
+)
+
+type CampaignTableProps = {
+ columns: Array>
+ 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
+ /** `` override (e.g. sticky headers). */
+ headerClassName?: string
+ rowClassName?: string | ((row: Row) => string | undefined)
+}
+
+export const CampaignTable = ({
+ columns,
+ rows,
+ rowKey,
+ empty,
+ caption,
+ className,
+ containerClassName,
+ headerClassName,
+ rowClassName,
+}: CampaignTableProps) => (
+
+
+ {caption ? {caption} : null}
+
+
+ {columns.map((column) => (
+ {column.head}
+ ))}
+
+
+
+ {rows.length === 0 && empty ? (
+
+
+ {empty}
+
+
+ ) : null}
+ {rows.map((row) => (
+
+ {columns.map((column) => (
+
+ {column.cell(row)}
+
+ ))}
+
+ ))}
+
+
+
+)
diff --git a/src/components/campaign/MunicipalityAdvisorAvatarStack.tsx b/src/components/campaign/MunicipalityAdvisorAvatarStack.tsx
index 5f3dac33..1f5a1909 100644
--- a/src/components/campaign/MunicipalityAdvisorAvatarStack.tsx
+++ b/src/components/campaign/MunicipalityAdvisorAvatarStack.tsx
@@ -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
diff --git a/src/components/campaign/MunicipalityBaselineCard.tsx b/src/components/campaign/MunicipalityBaselineCard.tsx
index 5492b305..ec66b029 100644
--- a/src/components/campaign/MunicipalityBaselineCard.tsx
+++ b/src/components/campaign/MunicipalityBaselineCard.tsx
@@ -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 =>
diff --git a/src/components/campaign/MunicipalityDossier.tsx b/src/components/campaign/MunicipalityDossier.tsx
index 65813902..3f33e0a5 100644
--- a/src/components/campaign/MunicipalityDossier.tsx
+++ b/src/components/campaign/MunicipalityDossier.tsx
@@ -23,7 +23,7 @@ import {
municipalityPriorityLabels,
politicalTrendBadgeVariant,
politicalTrendLabels,
-} from '@/utilities/municipalityUi'
+} from '@/utilities/municipalityLabels'
import type {
MunicipalityAdvisorSummary,
MunicipalityDetailViewModel,
diff --git a/src/components/campaign/MunicipalityFilters.tsx b/src/components/campaign/MunicipalityFilters.tsx
index 12094979..e877eeff 100644
--- a/src/components/campaign/MunicipalityFilters.tsx
+++ b/src/components/campaign/MunicipalityFilters.tsx
@@ -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 },
)
})
diff --git a/src/components/campaign/MunicipalityHeaderFilter.tsx b/src/components/campaign/MunicipalityHeaderFilter.tsx
index 2106f438..5ae338b5 100644
--- a/src/components/campaign/MunicipalityHeaderFilter.tsx
+++ b/src/components/campaign/MunicipalityHeaderFilter.tsx
@@ -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 (
- commit(next)}
- >
- {option.label}
-
- )
- })
+ ? visibleOptions.map((option) => (
+
+ commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value))
+ }
+ >
+ {option.label}
+
+ ))
: null}
{visibleOptions.length === 0 ? (
diff --git a/src/components/campaign/MunicipalityList.tsx b/src/components/campaign/MunicipalityList.tsx
index e54b8fdd..2851d371 100644
--- a/src/components/campaign/MunicipalityList.tsx
+++ b/src/components/campaign/MunicipalityList.tsx
@@ -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
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 })
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
,
): 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> => [
+ {
+ id: 'name',
+ mandatory: true,
+ head: (
+
+ Município
+
+ ),
+ cellClassName: 'max-w-52 whitespace-normal',
+ cell: (municipality) => (
+
+
+ {municipality.name}
+
+ {municipality.priority === 'alta' && isStaffView ? (
+ {municipalityPriorityLabels.alta}
+ ) : null}
+
+ ),
+ },
+ {
+ id: 'region',
+ head: (
+
+ Território
+
+ ),
+ cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
+ cell: (municipality) => municipality.region,
+ },
+ {
+ id: 'kind',
+ head: (
+
+ Tipo
+
+ ),
+ cell: (municipality) => municipalityKindLabels[municipality.kind],
+ },
+ {
+ id: 'votos',
+ head: (
+
+ ),
+ cellClassName: 'text-right',
+ cell: (municipality) =>
+ municipality.votePosition2022 ? (
+
+ ) : (
+ —
+ ),
+ },
+ ...(isStaffView
+ ? ([
+ {
+ id: 'advisors',
+ head: (
+
+ Assessores
+
+ ),
+ cell: (municipality) =>
+ isCoordinator ? (
+
+ ) : (
+
+ ),
+ },
+ {
+ id: 'trend',
+ head: (
+
+ Tendência
+
+ ),
+ cell: (municipality) => (
+
+ ),
+ },
+ {
+ id: 'expectedVotes',
+ head: (
+
+ Votos estimados
+
+ ),
+ cellClassName: 'relative overflow-visible align-middle text-center',
+ cell: (municipality) => (
+
+
+
+ ),
+ },
+ {
+ id: 'lastSignal',
+ head: (
+
+ Último sinal
+
+ ),
+ cell: (municipality) => (
+
+ ),
+ },
+ {
+ id: 'goalCoverage',
+ head: (
+
+ Cobertura da meta
+
+ ),
+ cell: (municipality) => (
+
+ ),
+ },
+ ] satisfies Array>)
+ : ([
+ {
+ id: 'lastUpdateAt',
+ head: (
+
+ Última atualização
+
+ ),
+ cell: (municipality) =>
+ municipality.lastUpdateAt
+ ? dateFormatter.format(new Date(municipality.lastUpdateAt))
+ : 'Sem atualização',
+ },
+ ] satisfies Array>)),
+]
+
+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 scrolls, so the sticky header
resolves against it. A sticky can't paint the row border, hence the
inset shadow standing in for it. */}
-
-
-
+
{sortSummary}. Coluna 2022: {concentrationHint}
-
-
-
-
- Município
-
-
- Território
-
-
- Tipo
-
-
- {isStaffView ? (
- <>
-
- Assessores
-
-
- Tendência
-
-
- Votos estimados
-
-
- Último sinal
-
-
- Cobertura da meta
-
- >
- ) : (
-
- Última atualização
-
- )}
-
-
-
- {municipalities.length === 0 ? (
-
-
-
-
-
- ) : null}
- {municipalities.map((municipality) => {
- const isPriority = municipality.priority === 'alta'
- return (
-
-
-
-
- {municipality.name}
-
- {isPriority && isStaffView ? (
- {municipalityPriorityLabels.alta}
- ) : null}
-
-
-
- {municipality.region}
-
- {municipalityKindLabels[municipality.kind]}
-
- {municipality.votePosition2022 ? (
-
- ) : (
- —
- )}
-
- {isStaffView ? (
- <>
-
- {isCoordinator ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
- {municipality.lastUpdateAt
- ? dateFormatter.format(new Date(municipality.lastUpdateAt))
- : 'Sem atualização'}
-
- )}
-
- )
- })}
-
-
-
+ >
+ }
+ columns={columns}
+ rows={municipalities}
+ rowKey={(municipality) => municipality.id}
+ empty={ }
+ />
>
)
}
diff --git a/src/components/campaign/MunicipalityListTrendControl.tsx b/src/components/campaign/MunicipalityListTrendControl.tsx
index 63c57121..885a2a8b 100644
--- a/src/components/campaign/MunicipalityListTrendControl.tsx
+++ b/src/components/campaign/MunicipalityListTrendControl.tsx
@@ -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 = {
diff --git a/src/components/campaign/MunicipalitySortableHead.tsx b/src/components/campaign/MunicipalitySortableHead.tsx
index 149ed42c..26c9a1a3 100644
--- a/src/components/campaign/MunicipalitySortableHead.tsx
+++ b/src/components/campaign/MunicipalitySortableHead.tsx
@@ -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
}
diff --git a/src/components/campaign/MunicipalityStrategyCard.tsx b/src/components/campaign/MunicipalityStrategyCard.tsx
index 78509127..11eb68cd 100644
--- a/src/components/campaign/MunicipalityStrategyCard.tsx
+++ b/src/components/campaign/MunicipalityStrategyCard.tsx
@@ -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')
diff --git a/src/components/campaign/MunicipalityStrategyForm.tsx b/src/components/campaign/MunicipalityStrategyForm.tsx
index b1348737..cb80ea11 100644
--- a/src/components/campaign/MunicipalityStrategyForm.tsx
+++ b/src/components/campaign/MunicipalityStrategyForm.tsx
@@ -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 = (
diff --git a/src/utilities/municipalityLabels.ts b/src/utilities/municipalityLabels.ts
new file mode 100644
index 00000000..0f53b5d2
--- /dev/null
+++ b/src/utilities/municipalityLabels.ts
@@ -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 = {
+ municipio: 'Município',
+ zona: 'Zona eleitoral',
+}
+
+export const municipalityPriorityLabels: Record, string> = {
+ alta: 'Prioritária',
+ normal: 'Normal',
+}
+
+export type PoliticalTrendStatus = NonNullable<
+ NonNullable['status']
+>
+
+export const politicalTrendLabels: Record = {
+ 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
diff --git a/src/utilities/municipalityListFilters.ts b/src/utilities/municipalityListFilters.ts
new file mode 100644
index 00000000..d30c4f60
--- /dev/null
+++ b/src/utilities/municipalityListFilters.ts
@@ -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
+ ).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).map(
+ (trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
+ ),
+ },
+]
+
+const municipalityFilterDefinitionByParam = Object.fromEntries(
+ municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
+) as Record
+
+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)
diff --git a/src/utilities/municipalityUi.ts b/src/utilities/municipalityListUrl.ts
similarity index 51%
rename from src/utilities/municipalityUi.ts
rename to src/utilities/municipalityListUrl.ts
index 3f0f30f4..fdbf46ac 100644
--- a/src/utilities/municipalityUi.ts
+++ b/src/utilities/municipalityListUrl.ts
@@ -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 = {
- municipio: 'Município',
- zona: 'Zona eleitoral',
-}
-
-export const municipalityPriorityLabels: Record, string> = {
- alta: 'Prioritária',
- normal: 'Normal',
-}
-
-export type PoliticalTrendStatus = NonNullable<
- NonNullable['status']
->
-
-export const politicalTrendLabels: Record = {
- 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 = {
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(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()
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,
- 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
- ).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).map(
- (trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
- ),
- },
-]
-
-const municipalityFilterDefinitionByParam = Object.fromEntries(
- municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
-) as Record
-
-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
diff --git a/src/utilities/municipalityMapData.ts b/src/utilities/municipalityMapData.ts
index 11d91e47..2220ce42 100644
--- a/src/utilities/municipalityMapData.ts
+++ b/src/utilities/municipalityMapData.ts
@@ -25,7 +25,7 @@ import {
parseMunicipalityListParams,
type MunicipalityListSearchParams,
type MunicipalityListState,
-} from '@/utilities/municipalityUi'
+} from '@/utilities/municipalityListUrl'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts
index bafe0526..fe978abe 100644
--- a/src/utilities/municipalityPageData.ts
+++ b/src/utilities/municipalityPageData.ts
@@ -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 } | null,
): Promise => {
const rowsByWhere = new Map>()
if (loadedScope) {
- rowsByWhere.set(JSON.stringify(loadedScope.where), Promise.resolve(loadedScope.rows))
+ rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows)
}
const facetRows = (omit: Partial): Promise => {
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()
let goalCoverageByMunicipalityID = new Map<
diff --git a/src/utilities/municipalitySignal.ts b/src/utilities/municipalitySignal.ts
new file mode 100644
index 00000000..0b623f10
--- /dev/null
+++ b/src/utilities/municipalitySignal.ts
@@ -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`
+}
diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts
index 48bc8d27..c74dffea 100644
--- a/src/utilities/municipalityViewModels.ts
+++ b/src/utilities/municipalityViewModels.ts
@@ -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 {
diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts
index 0abfb9ed..73a3b828 100644
--- a/tests/int/municipalityPageData.int.spec.ts
+++ b/tests/int/municipalityPageData.int.spec.ts
@@ -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')
diff --git a/tests/unit/municipalityUi.unit.spec.ts b/tests/unit/municipalityList.unit.spec.ts
similarity index 85%
rename from tests/unit/municipalityUi.unit.spec.ts
rename to tests/unit/municipalityList.unit.spec.ts
index 4bf884fa..8d5c0d19 100644
--- a/tests/unit/municipalityUi.unit.spec.ts
+++ b/tests/unit/municipalityList.unit.spec.ts
@@ -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[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)),
+ )
+ })
+})