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)), + ) + }) +})