mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Pass 2 W1-D1: sistema de listas (column defs + CampaignTable) + split do municipalityUi + B16+
- CampaignTable/CampaignTableHead: colunas como dado (id, head island, cell, seams B17 mandatory/defaultVisible); colSpan derivado - CampaignListFooter: contagem + paginacao compartilhadas - MunicipalityList reescrita sobre column defs; cards mobile inalterados - municipalityUi.ts (765/59 exports) dividido: municipalityListUrl / municipalityListFilters / municipalityLabels / municipalitySignal; contrato de URL byte-identico (pins verdes) - B16+ absorvido: useOptimistic no HeaderFilter (onNavigate dentro da transition), hrefs de opcao via serializador canonico confiavel (1 serializacao por opcao, pin de byte-igualdade), facet de municipio como slugs rotulados no cliente (-20KB), facets no mesmo Promise.all (seed por promise), catalogo DRY, clearMunicipalityListFilters - timeout explicito no int test de facets (2 bundles full-scope sob suite 47-way)
This commit is contained in:
parent
dc9a39f800
commit
03bf54dea2
24 changed files with 1067 additions and 680 deletions
|
|
@ -44,7 +44,7 @@ import {
|
|||
import {
|
||||
formatMunicipalityGeographyLabel,
|
||||
municipalityKindLabels,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
loadMunicipalityUpdatesFeed,
|
||||
parseMunicipalityUpdateFeedParams,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import config from '@payload-config'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
|
|
@ -20,16 +20,17 @@ import {
|
|||
isCampaignStaff,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
|
||||
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
buildMunicipalityListVisitHref,
|
||||
buildMunicipalityListVisitLabel,
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import {
|
||||
buildMunicipalityFiltersKey,
|
||||
buildMunicipalityListHref,
|
||||
buildMunicipalityListVisitHref,
|
||||
buildMunicipalityListVisitLabel,
|
||||
getCampaignScopeLabel,
|
||||
municipalityFilterOptionsForSlugs,
|
||||
resolveMunicipalityListUrl,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
|
||||
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
|
||||
import {
|
||||
assignMunicipalityAdvisorsFormAction,
|
||||
|
|
@ -83,7 +84,9 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
|
||||
const selectedAdvisorIDs = new Set(state.advisors ?? [])
|
||||
const columnFilterOptions = {
|
||||
name: municipalityFilterOptionsForSlugs(filterFacets.slugs),
|
||||
// Bare slugs: the filter popover labels them from the catalog on the
|
||||
// client, so the RSC payload never carries 435 name pairs (B16+).
|
||||
name: filterFacets.slugs,
|
||||
region: filterFacets.regions.map((region) => ({ value: region, label: region })),
|
||||
advisor: filterFacets.advisorIDs
|
||||
.flatMap((id) => {
|
||||
|
|
@ -130,16 +133,14 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
|
||||
state={state}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'município encontrado' : 'municípios encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildMunicipalityListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="município encontrado"
|
||||
plural="municípios encontrados"
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildMunicipalityListHref(state, page)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
|
|||
31
src/components/campaign/CampaignListFooter.tsx
Normal file
31
src/components/campaign/CampaignListFooter.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
|
||||
/**
|
||||
* The count line + pagination every campaign list renders under its results.
|
||||
* Server component: `hrefForPage` stays on the server (the pagination anchors
|
||||
* are the client leaves).
|
||||
*/
|
||||
export const CampaignListFooter = ({
|
||||
totalDocs,
|
||||
singular,
|
||||
plural,
|
||||
page,
|
||||
totalPages,
|
||||
hrefForPage,
|
||||
}: {
|
||||
totalDocs: number
|
||||
/** e.g. "município encontrado" */
|
||||
singular: string
|
||||
/** e.g. "municípios encontrados" */
|
||||
plural: string
|
||||
page: number
|
||||
totalPages: number
|
||||
hrefForPage: (page: number) => string
|
||||
}) => (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? singular : plural}
|
||||
</p>
|
||||
<CampaignListPagination page={page} totalPages={totalPages} hrefForPage={hrefForPage} />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
126
src/components/campaign/CampaignTable.tsx
Normal file
126
src/components/campaign/CampaignTable.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Campaign list-table system (Pass 2 W1): columns are data, the table is a
|
||||
* server component, interactivity lives in head/cell islands referenced by the
|
||||
* definitions. B17 (column picker) toggles visibility over `id`s; E10/E14 add
|
||||
* a definition instead of a new table.
|
||||
*/
|
||||
export type CampaignTableColumn<Row> = {
|
||||
/** Stable identifier — the future column-picker key (B17). */
|
||||
id: string
|
||||
/**
|
||||
* Full `<th>` node. Plain labels use `<CampaignTableHead>`; rich columns
|
||||
* render their own head island (e.g. `MunicipalitySortableHead`), which owns
|
||||
* `aria-sort` and the filter popover.
|
||||
*/
|
||||
head: ReactNode
|
||||
cell: (row: Row) => ReactNode
|
||||
cellClassName?: string | ((row: Row) => string | undefined)
|
||||
/** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */
|
||||
mandatory?: boolean
|
||||
defaultVisible?: boolean
|
||||
}
|
||||
|
||||
/** Plain (non-sortable) header cell for column definitions. */
|
||||
export const CampaignTableHead = ({
|
||||
align = 'left',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<TableHead
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TableHead>
|
||||
)
|
||||
|
||||
type CampaignTableProps<Row> = {
|
||||
columns: Array<CampaignTableColumn<Row>>
|
||||
rows: readonly Row[]
|
||||
rowKey: (row: Row) => string | number
|
||||
/** Rendered inside a full-width row when there are no rows — the header chrome stays mounted. */
|
||||
empty?: ReactNode
|
||||
/** Screen-reader table description. */
|
||||
caption?: ReactNode
|
||||
/** Outer wrapper (border/rounding live here). */
|
||||
className?: string
|
||||
/** `ui/Table` scroll container override (e.g. municipality's `overflow-x-visible`). */
|
||||
containerClassName?: string
|
||||
/** `<thead>` override (e.g. sticky headers). */
|
||||
headerClassName?: string
|
||||
rowClassName?: string | ((row: Row) => string | undefined)
|
||||
}
|
||||
|
||||
export const CampaignTable = <Row,>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
empty,
|
||||
caption,
|
||||
className,
|
||||
containerClassName,
|
||||
headerClassName,
|
||||
rowClassName,
|
||||
}: CampaignTableProps<Row>) => (
|
||||
<div className={cn('overflow-hidden rounded-xl border', className)}>
|
||||
<Table containerClassName={containerClassName}>
|
||||
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
|
||||
<TableHeader className={headerClassName}>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<Fragment key={column.id}>{column.head}</Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && empty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="whitespace-normal">
|
||||
{empty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row)
|
||||
: column.cellClassName
|
||||
}
|
||||
>
|
||||
{column.cell(row)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
municipalityPriorityLabels,
|
||||
politicalTrendBadgeVariant,
|
||||
politicalTrendLabels,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import type {
|
||||
MunicipalityAdvisorSummary,
|
||||
MunicipalityDetailViewModel,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
'use client'
|
||||
|
||||
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMemo, useOptimistic, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { municipalityPriorityLabels } from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
buildMunicipalityFiltersKey,
|
||||
buildMunicipalityFilterOptionHref,
|
||||
clearMunicipalityAdvisorFilters,
|
||||
clearMunicipalityMultiFilter,
|
||||
clearMunicipalityNameFilters,
|
||||
|
|
@ -19,22 +20,26 @@ import {
|
|||
getMunicipalityMultiFilterValues,
|
||||
getMunicipalitySingleFilterValue,
|
||||
isMunicipalityColumnFilterActive,
|
||||
municipalityPriorityLabels,
|
||||
municipalityFilterOptionsForSlugs,
|
||||
toggleMunicipalityExclusiveFilterValue,
|
||||
toggleMunicipalityMultiFilterValue,
|
||||
toggleMunicipalityPriorityFilter,
|
||||
type MunicipalityFilterOption,
|
||||
type MunicipalityFilterParam,
|
||||
type MunicipalityListState,
|
||||
type MunicipalityMultiFilterParam,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import { type MunicipalityListState } from '@/utilities/municipalityListUrl'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
type MunicipalityHeaderFilterProps = {
|
||||
state: MunicipalityListState
|
||||
filterParam: MunicipalityFilterParam
|
||||
/** Options already narrowed by the other active filters (server-computed). */
|
||||
options?: MunicipalityFilterOption[]
|
||||
/**
|
||||
* Options already narrowed by the other active filters (server-computed).
|
||||
* The Município column ships bare slugs — labels come from the catalog here
|
||||
* on the client, so the RSC payload never carries 435 name pairs (B16+).
|
||||
*/
|
||||
options?: MunicipalityFilterOption[] | readonly string[]
|
||||
/** Staff-only Prioritária checkbox inside the Município filter. */
|
||||
showPriorityFilter?: boolean
|
||||
}
|
||||
|
|
@ -94,29 +99,27 @@ const FilterOptionLink = ({
|
|||
export const MunicipalityHeaderFilter = ({
|
||||
state,
|
||||
filterParam,
|
||||
options: optionsOverride,
|
||||
options: optionsInput,
|
||||
showPriorityFilter = false,
|
||||
}: MunicipalityHeaderFilterProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
/**
|
||||
* Optimistic selection, tagged with the URL state it was derived from: when the
|
||||
* server answer lands the tag stops matching and the truth takes over — no
|
||||
* effect racing the transition (and no reverting a still-pending click).
|
||||
* Optimistic selection: set inside the navigation transition (see
|
||||
* `CampaignTransitionAnchor.onNavigate`), so React shows the clicked state
|
||||
* immediately and discards it for the server truth when the transition
|
||||
* settles — no manual base-key bookkeeping, no reverting a pending click.
|
||||
*/
|
||||
const [optimistic, setOptimistic] = useState<{
|
||||
baseKey: string
|
||||
next: MunicipalityListState
|
||||
} | null>(null)
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
|
||||
const definition = getMunicipalityFilterDefinition(filterParam)
|
||||
const definitionOptions = definition.options
|
||||
const options = useMemo(
|
||||
() => optionsOverride ?? definitionOptions ?? [],
|
||||
[optionsOverride, definitionOptions],
|
||||
)
|
||||
const stateKey = buildMunicipalityFiltersKey(state)
|
||||
const viewState = optimistic?.baseKey === stateKey ? optimistic.next : state
|
||||
const options = useMemo(() => {
|
||||
if (!optionsInput) return definitionOptions ?? []
|
||||
return typeof optionsInput[0] === 'string' || optionsInput.length === 0
|
||||
? municipalityFilterOptionsForSlugs(optionsInput as readonly string[])
|
||||
: (optionsInput as MunicipalityFilterOption[])
|
||||
}, [optionsInput, definitionOptions])
|
||||
const isActive = isMunicipalityColumnFilterActive(viewState, filterParam)
|
||||
const searchable = options.length >= SEARCHABLE_OPTION_THRESHOLD
|
||||
|
||||
|
|
@ -131,7 +134,7 @@ export const MunicipalityHeaderFilter = ({
|
|||
: options
|
||||
|
||||
const commit = (next: MunicipalityListState) => {
|
||||
setOptimistic({ baseKey: stateKey, next })
|
||||
setOptimisticState(next)
|
||||
if (definition.selection !== 'multi') setOpen(false)
|
||||
}
|
||||
|
||||
|
|
@ -282,21 +285,22 @@ export const MunicipalityHeaderFilter = ({
|
|||
})
|
||||
: null}
|
||||
|
||||
{/* Option hrefs ride the cheap canonical-toggle serializer; the full
|
||||
toggle (state for the optimistic commit) runs only on click. */}
|
||||
{multiParam
|
||||
? visibleOptions.map((option) => {
|
||||
const next = toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value)
|
||||
return (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterHref(next)}
|
||||
selected={selectedMulti.includes(option.value)}
|
||||
checkbox
|
||||
onChoose={() => commit(next)}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
)
|
||||
})
|
||||
? visibleOptions.map((option) => (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterOptionHref(viewState, multiParam, option.value)}
|
||||
selected={selectedMulti.includes(option.value)}
|
||||
checkbox
|
||||
onChoose={() =>
|
||||
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value))
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
))
|
||||
: null}
|
||||
|
||||
{visibleOptions.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
|||
import Link from 'next/link'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/CampaignTable'
|
||||
import {
|
||||
MissingAdvisorBadge,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
|
|
@ -21,33 +22,32 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { formatMunicipalityVoteRank, formatMunicipalityVoteShare } from '@/lib/municipalityVoteRank'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
formatMunicipalityConcentrationHint,
|
||||
formatMunicipalityGeographyLabel,
|
||||
municipalityKindLabels,
|
||||
municipalityPriorityLabels,
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
clearMunicipalityListFilters,
|
||||
type MunicipalityFilterOption,
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
formatMunicipalityListSortSummary,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import {
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalityKindLabels,
|
||||
municipalityPriorityLabels,
|
||||
municipalitySignalAgeInDays,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityFilterOption,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalitySignal'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
|
|
@ -67,7 +67,8 @@ type MunicipalityStaffFormAction = (
|
|||
) => Promise<CampaignFormActionState>
|
||||
|
||||
type MunicipalityColumnFilterOptions = {
|
||||
name: MunicipalityFilterOption[]
|
||||
/** Bare catalog slugs — labeled on the client (B16+ payload trim). */
|
||||
name: readonly string[]
|
||||
region: MunicipalityFilterOption[]
|
||||
advisor: MunicipalityFilterOption[]
|
||||
}
|
||||
|
|
@ -174,7 +175,7 @@ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState })
|
|||
<EmptyContent>
|
||||
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
|
||||
<CampaignTransitionAnchor
|
||||
href={buildMunicipalityListHref({ page: 1, sort: state.sort, dir: state.dir }, 1)}
|
||||
href={buildMunicipalityListHref(clearMunicipalityListFilters(state), 1)}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
|
|
@ -199,17 +200,214 @@ const advisorNames = (
|
|||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
|
||||
|
||||
export const MunicipalityList = ({
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
/** The desktop table as column definitions (Pass 2 W1 list system). */
|
||||
const municipalityListColumns = ({
|
||||
state,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
advisorOptions,
|
||||
columnFilterOptions,
|
||||
advisorNamesById,
|
||||
advisorOptions,
|
||||
trendFormAction,
|
||||
advisorsFormAction,
|
||||
state,
|
||||
}: MunicipalityListProps) => {
|
||||
concentrationHint,
|
||||
signalHint,
|
||||
deficitHint,
|
||||
}: MunicipalityListProps & {
|
||||
concentrationHint: string
|
||||
signalHint: string
|
||||
deficitHint: string
|
||||
}): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
>
|
||||
Município
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cellClassName: 'max-w-52 whitespace-normal',
|
||||
cell: (municipality) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
>
|
||||
Território
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (municipality) => municipality.region,
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
|
||||
Tipo
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => municipalityKindLabels[municipality.kind],
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
tooltip={concentrationHint}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<VotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
...(isStaffView
|
||||
? ([
|
||||
{
|
||||
id: 'advisors',
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
>
|
||||
Assessores
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
|
||||
Tendência
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
formAction={trendFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||||
Votos estimados
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="frescor" tooltip={signalHint}>
|
||||
Último sinal
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="deficit" tooltip={deficitHint}>
|
||||
Cobertura da meta
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
layout="compact"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
head: (
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)),
|
||||
]
|
||||
|
||||
export const MunicipalityList = (props: MunicipalityListProps) => {
|
||||
const {
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
advisorOptions,
|
||||
trendFormAction,
|
||||
state,
|
||||
} = props
|
||||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
|
||||
const concentrationHint = formatMunicipalityConcentrationHint()
|
||||
|
|
@ -217,8 +415,8 @@ export const MunicipalityList = ({
|
|||
// The scenario picker is client state, so the server can only order by one
|
||||
// scenario — named here so the ordering never looks arbitrary.
|
||||
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
|
||||
// Keep in sync with the header row below.
|
||||
const columnCount = isStaffView ? 9 : 5
|
||||
|
||||
const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint })
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -295,7 +493,7 @@ export const MunicipalityList = ({
|
|||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
formAction={props.advisorsFormAction}
|
||||
/>
|
||||
) : names.length ? (
|
||||
names.join(', ')
|
||||
|
|
@ -317,180 +515,20 @@ export const MunicipalityList = ({
|
|||
{/* No inner scroller: the app shell's <main> scrolls, so the sticky header
|
||||
resolves against it. A sticky <th> can't paint the row border, hence the
|
||||
inset shadow standing in for it. */}
|
||||
<div data-view="desktop-table" className="hidden rounded-xl border md:block">
|
||||
<Table containerClassName="overflow-x-visible">
|
||||
<TableCaption className="sr-only">
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible md:block"
|
||||
containerClassName="overflow-x-visible"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}. Coluna 2022: {concentrationHint}
|
||||
</TableCaption>
|
||||
<TableHeader className="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0">
|
||||
<TableRow>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
>
|
||||
Município
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
>
|
||||
Território
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
|
||||
Tipo
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
tooltip={concentrationHint}
|
||||
/>
|
||||
{isStaffView ? (
|
||||
<>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
>
|
||||
Assessores
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
|
||||
Tendência
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||||
Votos estimados
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
tooltip={signalHint}
|
||||
>
|
||||
Último sinal
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
tooltip={deficitHint}
|
||||
>
|
||||
Cobertura da meta
|
||||
</MunicipalitySortableHead>
|
||||
</>
|
||||
) : (
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.length === 0 ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columnCount} className="whitespace-normal">
|
||||
<MunicipalityListEmptyState state={state} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<TableRow key={municipality.id}>
|
||||
<TableCell className="max-w-52 whitespace-normal">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{isPriority && isStaffView ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
|
||||
{municipality.region}
|
||||
</TableCell>
|
||||
<TableCell>{municipalityKindLabels[municipality.kind]}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{municipality.votePosition2022 ? (
|
||||
<VotePositionReadout
|
||||
position={municipality.votePosition2022}
|
||||
layout="table"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{isStaffView ? (
|
||||
<>
|
||||
<TableCell>
|
||||
{isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={isPriority}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
formAction={trendFormAction}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="relative overflow-visible align-middle text-center">
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SignalAgeReadout
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
layout="table"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
layout="compact"
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<TableCell>
|
||||
{municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização'}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
67
src/utilities/municipalityLabels.ts
Normal file
67
src/utilities/municipalityLabels.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Municipality display labels shared by list, detail, dossier and map
|
||||
* surfaces. Client-safe: types + string tables only (split out of the former
|
||||
* `municipalityUi.ts` in Pass 2 W1).
|
||||
*/
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
|
||||
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
|
||||
municipio: 'Município',
|
||||
zona: 'Zona eleitoral',
|
||||
}
|
||||
|
||||
export const municipalityPriorityLabels: Record<NonNullable<Municipality['priority']>, string> = {
|
||||
alta: 'Prioritária',
|
||||
normal: 'Normal',
|
||||
}
|
||||
|
||||
export type PoliticalTrendStatus = NonNullable<
|
||||
NonNullable<Municipality['politicalTrend']>['status']
|
||||
>
|
||||
|
||||
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
|
||||
favoravel: 'Favorável',
|
||||
neutra: 'Neutra',
|
||||
desfavoravel: 'Desfavorável',
|
||||
}
|
||||
|
||||
export const politicalTrendBadgeVariant = {
|
||||
favoravel: 'estimate-confirmed',
|
||||
neutra: 'secondary',
|
||||
desfavoravel: 'destructive',
|
||||
} as const
|
||||
|
||||
export const municipalityListCoverageLabels: Record<'com_assessor' | 'sem_assessor', string> = {
|
||||
com_assessor: 'Com assessor',
|
||||
sem_assessor: 'Sem assessor',
|
||||
}
|
||||
|
||||
export const formatMunicipalityConcentrationHint = (
|
||||
totalUnits: number = municipalityCatalog.length,
|
||||
): string =>
|
||||
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
|
||||
|
||||
export const getCampaignScopeLabel = (
|
||||
role: CampaignUser['role'],
|
||||
municipalityCount: number,
|
||||
): string => {
|
||||
if (role === 'advisor') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
|
||||
}
|
||||
if (role === 'leader') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
|
||||
}
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
|
||||
}
|
||||
|
||||
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
|
||||
export const formatMunicipalityGeographyLabel = (municipality: {
|
||||
region: string
|
||||
kind: Municipality['kind']
|
||||
zoneNumber?: number | null
|
||||
}): string =>
|
||||
municipality.kind === 'zona' && municipality.zoneNumber != null
|
||||
? `${municipality.region} · ZE ${municipality.zoneNumber}`
|
||||
: municipality.region
|
||||
324
src/utilities/municipalityListFilters.ts
Normal file
324
src/utilities/municipalityListFilters.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Municipality list filter affordances (B16 header popovers + mobile bar):
|
||||
* definitions, selection helpers and the active-filters summary. Split out of
|
||||
* the former `municipalityUi.ts` in Pass 2 W1.
|
||||
*/
|
||||
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
municipalityListStateToRawParams,
|
||||
parseMunicipalityListParams,
|
||||
serializeCanonicalMunicipalityListSearchParams,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import {
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
politicalTrendLabels,
|
||||
type PoliticalTrendStatus,
|
||||
} from '@/utilities/municipalityLabels'
|
||||
|
||||
/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
|
||||
export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
|
||||
|
||||
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
|
||||
|
||||
export type MunicipalityFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Catalog labels for a (possibly cross-filtered) slug list. */
|
||||
export const municipalityFilterOptionsForSlugs = (
|
||||
slugs: readonly string[],
|
||||
): MunicipalityFilterOption[] =>
|
||||
slugs.map((slug) => ({ value: slug, label: getMunicipalityCatalogEntry(slug)?.name ?? slug }))
|
||||
|
||||
export type MunicipalityFilterDefinition = {
|
||||
param: MunicipalityFilterParam
|
||||
label: string
|
||||
/** Shown for exclusive single-select clear row; omitted for toggle/multi. */
|
||||
allLabel?: string
|
||||
staffOnly: boolean
|
||||
selection: MunicipalityFilterSelectionMode
|
||||
/** Absent for the params whose options are cross-filtered server-side. */
|
||||
options?: MunicipalityFilterOption[]
|
||||
}
|
||||
|
||||
export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
|
||||
{
|
||||
param: 'name',
|
||||
label: 'Município',
|
||||
staffOnly: false,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'region',
|
||||
label: 'Território',
|
||||
staffOnly: false,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'kind',
|
||||
label: 'Tipo',
|
||||
allLabel: 'Todos',
|
||||
staffOnly: false,
|
||||
selection: 'single',
|
||||
options: (
|
||||
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
|
||||
).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
|
||||
},
|
||||
{
|
||||
param: 'advisor',
|
||||
label: 'Assessores',
|
||||
staffOnly: true,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'coverage',
|
||||
label: 'Assessoria',
|
||||
staffOnly: true,
|
||||
selection: 'toggle',
|
||||
options: (
|
||||
Object.keys(municipalityListCoverageLabels) as Array<
|
||||
keyof typeof municipalityListCoverageLabels
|
||||
>
|
||||
).map((coverage) => ({ value: coverage, label: municipalityListCoverageLabels[coverage] })),
|
||||
},
|
||||
{
|
||||
param: 'trend',
|
||||
label: 'Tendência',
|
||||
staffOnly: true,
|
||||
selection: 'multi',
|
||||
options: (Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
|
||||
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const municipalityFilterDefinitionByParam = Object.fromEntries(
|
||||
municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
|
||||
) as Record<MunicipalityFilterParam, MunicipalityFilterDefinition>
|
||||
|
||||
export const getMunicipalityFilterDefinition = (
|
||||
param: MunicipalityFilterParam,
|
||||
): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
|
||||
|
||||
export const getMunicipalitySingleFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: 'kind' | 'coverage',
|
||||
): string | undefined => state[param]
|
||||
|
||||
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
|
||||
|
||||
export const getMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'region') return state.regions ?? []
|
||||
if (param === 'slug') return state.slugs ?? []
|
||||
if (param === 'trend') return state.trends ?? []
|
||||
return (state.advisors ?? []).map(String)
|
||||
}
|
||||
|
||||
const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
|
||||
parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
|
||||
|
||||
/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
|
||||
export const applyMunicipalityKindFilter = (
|
||||
state: MunicipalityListState,
|
||||
value: string | undefined,
|
||||
): MunicipalityListState =>
|
||||
parseMunicipalityListParams({
|
||||
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
kind: value || undefined,
|
||||
})
|
||||
|
||||
/** Toggle exclusivity: clicking the active value clears (coverage). */
|
||||
export const toggleMunicipalityExclusiveFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: 'coverage',
|
||||
value: string,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({
|
||||
...state,
|
||||
[param]: state[param] === value ? undefined : (value as MunicipalityListState['coverage']),
|
||||
})
|
||||
|
||||
export const toggleMunicipalityPriorityFilter = (
|
||||
state: MunicipalityListState,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({
|
||||
...state,
|
||||
priority: state.priority === 'alta' ? undefined : 'alta',
|
||||
})
|
||||
|
||||
/**
|
||||
* Writes the RAW param and lets `parseMunicipalityListParams` validate, so no
|
||||
* branch has to cast a `string[]` into the state's narrow value types.
|
||||
*/
|
||||
const setMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
values: string[],
|
||||
): MunicipalityListState =>
|
||||
parseMunicipalityListParams({
|
||||
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
[param]: values,
|
||||
})
|
||||
|
||||
export const toggleMunicipalityMultiFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
value: string,
|
||||
): MunicipalityListState => {
|
||||
const current = getMunicipalityMultiFilterValues(state, param)
|
||||
return setMunicipalityMultiFilterValues(
|
||||
state,
|
||||
param,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
export const clearMunicipalityMultiFilter = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
): MunicipalityListState => setMunicipalityMultiFilterValues(state, param, [])
|
||||
|
||||
/** Clear municipality-column filters (priority + selected slugs). */
|
||||
export const clearMunicipalityNameFilters = (state: MunicipalityListState): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({ ...state, priority: undefined, slugs: undefined })
|
||||
|
||||
/** Clear advisor-column filters (com/sem assessor + selected advisors). */
|
||||
export const clearMunicipalityAdvisorFilters = (
|
||||
state: MunicipalityListState,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({ ...state, coverage: undefined, advisors: undefined })
|
||||
|
||||
/**
|
||||
* The "Limpar" contract shared by the filter bar and the empty state: drop
|
||||
* every filter and the search, keep the sort.
|
||||
*/
|
||||
export const clearMunicipalityListFilters = (
|
||||
state: MunicipalityListState,
|
||||
): MunicipalityListState => ({ page: 1, sort: state.sort, dir: state.dir })
|
||||
|
||||
export const buildMunicipalityFilterHref = (next: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref(next, 1)
|
||||
|
||||
const trendStatusCount = Object.keys(politicalTrendLabels).length
|
||||
|
||||
const isPoliticalTrendStatus = (value: string): value is PoliticalTrendStatus =>
|
||||
value in politicalTrendLabels
|
||||
|
||||
/**
|
||||
* B16+ href fast path: option rows only need the WOULD-BE href, so the toggled
|
||||
* state is derived with the same canonical rules the parser applies (advisors
|
||||
* numerically sorted, the full trend set collapsing to "todas" = absent)
|
||||
* instead of a full parse round-trip per rendered option — the popover renders
|
||||
* up to 435 of these per open.
|
||||
*/
|
||||
export const buildMunicipalityFilterOptionHref = (
|
||||
canonicalState: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
value: string,
|
||||
): string => {
|
||||
const current = getMunicipalityMultiFilterValues(canonicalState, param)
|
||||
const toggled = current.includes(value)
|
||||
? current.filter((entry) => entry !== value)
|
||||
: [...current, value]
|
||||
|
||||
const next: MunicipalityListState = { ...canonicalState, page: 1 }
|
||||
if (param === 'region') {
|
||||
const regions = toggled.filter(isBahiaIdentityTerritory)
|
||||
if (regions.length) next.regions = regions
|
||||
else delete next.regions
|
||||
} else if (param === 'slug') {
|
||||
const slugs = toggled.filter((slug) => getMunicipalityCatalogEntry(slug) !== undefined)
|
||||
if (slugs.length) next.slugs = slugs
|
||||
else delete next.slugs
|
||||
} else if (param === 'advisor') {
|
||||
const advisors = toggled
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0)
|
||||
.sort((left, right) => left - right)
|
||||
if (advisors.length) next.advisors = advisors
|
||||
else delete next.advisors
|
||||
} else {
|
||||
const trends = toggled.filter(isPoliticalTrendStatus)
|
||||
if (trends.length && trends.length < trendStatusCount) next.trends = trends
|
||||
else delete next.trends
|
||||
}
|
||||
|
||||
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
|
||||
return query ? `/campanha/municipios?${query}` : '/campanha/municipios'
|
||||
}
|
||||
|
||||
export const isMunicipalityColumnFilterActive = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityFilterParam,
|
||||
): boolean => {
|
||||
switch (param) {
|
||||
case 'name':
|
||||
return Boolean(state.priority || state.slugs?.length)
|
||||
case 'region':
|
||||
return Boolean(state.regions?.length)
|
||||
case 'advisor':
|
||||
// The Assessores popover also owns the com/sem assessor toggle.
|
||||
return Boolean(state.advisors?.length || state.coverage)
|
||||
case 'kind':
|
||||
return Boolean(state.kind)
|
||||
case 'coverage':
|
||||
return Boolean(state.coverage)
|
||||
case 'trend':
|
||||
return Boolean(state.trends?.length)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
|
||||
const firstNamesLabel = (names: string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const formatMunicipalityActiveFiltersSummary = (
|
||||
state: MunicipalityListState,
|
||||
): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.priority) parts.push(municipalityPriorityLabels.alta)
|
||||
if (state.slugs?.length) {
|
||||
parts.push(
|
||||
firstNamesLabel(
|
||||
state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
|
||||
if (state.kind) parts.push(municipalityKindLabels[state.kind])
|
||||
if (state.advisors?.length) {
|
||||
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
|
||||
}
|
||||
if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
|
||||
if (state.trends?.length) {
|
||||
parts.push(
|
||||
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
|
||||
|
||||
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
|
||||
const summary = formatMunicipalityActiveFiltersSummary(state)
|
||||
if (!summary) return null
|
||||
|
||||
const label = `Municípios · ${summary}`
|
||||
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
|
||||
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
|
||||
}
|
||||
|
||||
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref(state, 1)
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
/**
|
||||
* Municipality list URL contract: state type, param parsing/canonicalization,
|
||||
* Payload `where`, serialization and sort. Split out of the former
|
||||
* `municipalityUi.ts` in Pass 2 W1. The URL contract is frozen — B18 (saved
|
||||
* filters) depends on it.
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
|
|
@ -13,37 +17,13 @@ import {
|
|||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { latestIsoTimestamp } from '@/utilities/campaignTime'
|
||||
import { politicalTrendLabels, type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
import type { Municipality } from '@/payload-types'
|
||||
|
||||
export const municipalityPageSize = 25
|
||||
|
||||
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
|
||||
municipio: 'Município',
|
||||
zona: 'Zona eleitoral',
|
||||
}
|
||||
|
||||
export const municipalityPriorityLabels: Record<NonNullable<Municipality['priority']>, string> = {
|
||||
alta: 'Prioritária',
|
||||
normal: 'Normal',
|
||||
}
|
||||
|
||||
export type PoliticalTrendStatus = NonNullable<
|
||||
NonNullable<Municipality['politicalTrend']>['status']
|
||||
>
|
||||
|
||||
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
|
||||
favoravel: 'Favorável',
|
||||
neutra: 'Neutra',
|
||||
desfavoravel: 'Desfavorável',
|
||||
}
|
||||
|
||||
export const politicalTrendBadgeVariant = {
|
||||
favoravel: 'estimate-confirmed',
|
||||
neutra: 'secondary',
|
||||
desfavoravel: 'destructive',
|
||||
} as const
|
||||
|
||||
export type MunicipalityListSortKey =
|
||||
| 'name'
|
||||
| 'region'
|
||||
|
|
@ -65,8 +45,7 @@ export type MunicipalityListSortDirection = 'asc' | 'desc'
|
|||
* the candidate's own 2022 vote, this ordering stays close to the previous
|
||||
* `votos` default instead of surfacing deserts with inflated goals.
|
||||
*/
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'desc'
|
||||
const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
|
||||
|
||||
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
|
||||
name: 'Município',
|
||||
|
|
@ -108,7 +87,7 @@ export type MunicipalityListState = {
|
|||
|
||||
export type MunicipalityListSearchParams = CampaignListRawSearchParams
|
||||
|
||||
export const municipalityListParamNames = [
|
||||
const municipalityListParamNames = [
|
||||
'q',
|
||||
'region',
|
||||
'slug',
|
||||
|
|
@ -123,11 +102,6 @@ export const municipalityListParamNames = [
|
|||
'page',
|
||||
] as const
|
||||
|
||||
const municipalitySlugSet = new Set(municipalityCatalog.map((entry) => entry.slug))
|
||||
const municipalityNameBySlug = new Map(
|
||||
municipalityCatalog.map((entry) => [entry.slug, entry.name] as const),
|
||||
)
|
||||
|
||||
const municipalityListParamNameSet = new Set<string>(municipalityListParamNames)
|
||||
|
||||
// Derived from the label record (total over the union), so a new sort key is
|
||||
|
|
@ -157,55 +131,6 @@ export const resolveMunicipalityListSort = (
|
|||
return { sort, dir }
|
||||
}
|
||||
|
||||
export const formatMunicipalityConcentrationHint = (
|
||||
totalUnits: number = municipalityCatalog.length,
|
||||
): string =>
|
||||
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
|
||||
|
||||
/**
|
||||
* E9 frescor — the last time ANYBODY recorded something here: a staff update
|
||||
* or a leadership pledge declaration/estimate. One rule, shared by the server
|
||||
* ordering (`municipalityPageData.ts`) and the list cell, so "há N dias"
|
||||
* never disagrees with the position in the queue.
|
||||
*/
|
||||
export const resolveMunicipalityLastSignalAt = (
|
||||
lastUpdateAt: string | null,
|
||||
lastPledgeAt: string | null,
|
||||
): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
|
||||
|
||||
/**
|
||||
* Days since the last signal, floored. The research report only says a
|
||||
* commitment left untouched "for weeks" is worth less, so the threshold below
|
||||
* is a convention (3 weeks), not a measured decay curve.
|
||||
*/
|
||||
export const MUNICIPALITY_COLD_SIGNAL_DAYS = 21
|
||||
|
||||
export const municipalitySignalAgeInDays = (
|
||||
lastSignalAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): number | null => {
|
||||
if (!lastSignalAt) return null
|
||||
const elapsed = now.getTime() - new Date(lastSignalAt).getTime()
|
||||
if (Number.isNaN(elapsed)) return null
|
||||
return Math.max(0, Math.floor(elapsed / 86_400_000))
|
||||
}
|
||||
|
||||
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
|
||||
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
|
||||
|
||||
/**
|
||||
* "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
|
||||
* Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
|
||||
* "anteontem" and minute/hour granularity, which breaks both the day-based
|
||||
* cold threshold and the tabular-nums scan down the column.
|
||||
*/
|
||||
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
|
||||
if (ageInDays === null) return 'Sem sinal'
|
||||
if (ageInDays === 0) return 'hoje'
|
||||
if (ageInDays === 1) return 'há 1 dia'
|
||||
return `há ${ageInDays} dias`
|
||||
}
|
||||
|
||||
export const formatMunicipalityListSortSummary = (
|
||||
sort: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
|
|
@ -250,7 +175,7 @@ const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
|
|||
const slugs: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const token of allParamValues(raw)) {
|
||||
if (!municipalitySlugSet.has(token) || seen.has(token)) continue
|
||||
if (!isMunicipalitySlug(token) || seen.has(token)) continue
|
||||
seen.add(token)
|
||||
slugs.push(token)
|
||||
}
|
||||
|
|
@ -283,7 +208,7 @@ const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendSta
|
|||
return trends.length < politicalTrendStatusSet.size ? trends : []
|
||||
}
|
||||
|
||||
const municipalityListStateToRawParams = (
|
||||
export const municipalityListStateToRawParams = (
|
||||
state: MunicipalityListState,
|
||||
page = state.page,
|
||||
): MunicipalityListSearchParams => ({
|
||||
|
|
@ -363,11 +288,15 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
||||
export const buildMunicipalityListSearchParams = (
|
||||
state: MunicipalityListState,
|
||||
page = state.page,
|
||||
/**
|
||||
* Serializes a state that is ALREADY canonical (came out of
|
||||
* `parseMunicipalityListParams`, or was derived from a canonical state by a
|
||||
* rule-preserving toggle). Kept private-ish so the public builder below stays
|
||||
* the only entry point that accepts arbitrary states.
|
||||
*/
|
||||
export const serializeCanonicalMunicipalityListSearchParams = (
|
||||
canonicalState: MunicipalityListState,
|
||||
): URLSearchParams => {
|
||||
const canonicalState = parseMunicipalityListParams(municipalityListStateToRawParams(state, page))
|
||||
const params = new URLSearchParams()
|
||||
const resolvedSort = canonicalState.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
|
||||
const resolvedDir = canonicalState.dir ?? defaultMunicipalityListSortDir(resolvedSort)
|
||||
|
|
@ -397,6 +326,14 @@ export const buildMunicipalityListSearchParams = (
|
|||
return params
|
||||
}
|
||||
|
||||
const buildMunicipalityListSearchParams = (
|
||||
state: MunicipalityListState,
|
||||
page = state.page,
|
||||
): URLSearchParams =>
|
||||
serializeCanonicalMunicipalityListSearchParams(
|
||||
parseMunicipalityListParams(municipalityListStateToRawParams(state, page)),
|
||||
)
|
||||
|
||||
export const buildMunicipalityFiltersKey = (state: MunicipalityListState): string =>
|
||||
buildMunicipalityListSearchParams(state).toString()
|
||||
|
||||
|
|
@ -427,7 +364,7 @@ export const buildMunicipalitySortHref = (
|
|||
return buildMunicipalityListHref(updatedState, 1)
|
||||
}
|
||||
|
||||
export const formatMunicipalitySortOptionLabel = (
|
||||
const formatMunicipalitySortOptionLabel = (
|
||||
key: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
): string => {
|
||||
|
|
@ -489,277 +426,3 @@ export const resolveMunicipalityListUrl = (
|
|||
basePath: '/campanha/municipios',
|
||||
totalPages,
|
||||
})
|
||||
|
||||
export const municipalityListCoverageLabels: Record<
|
||||
NonNullable<MunicipalityListState['coverage']>,
|
||||
string
|
||||
> = {
|
||||
com_assessor: 'Com assessor',
|
||||
sem_assessor: 'Sem assessor',
|
||||
}
|
||||
|
||||
/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
|
||||
export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
|
||||
|
||||
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
|
||||
|
||||
export type MunicipalityFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Catalog labels for a (possibly cross-filtered) slug list. */
|
||||
export const municipalityFilterOptionsForSlugs = (
|
||||
slugs: readonly string[],
|
||||
): MunicipalityFilterOption[] =>
|
||||
slugs.map((slug) => ({ value: slug, label: municipalityNameBySlug.get(slug) ?? slug }))
|
||||
|
||||
export type MunicipalityFilterDefinition = {
|
||||
param: MunicipalityFilterParam
|
||||
label: string
|
||||
/** Shown for exclusive single-select clear row; omitted for toggle/multi. */
|
||||
allLabel?: string
|
||||
staffOnly: boolean
|
||||
selection: MunicipalityFilterSelectionMode
|
||||
/** Absent for the params whose options are cross-filtered server-side. */
|
||||
options?: MunicipalityFilterOption[]
|
||||
}
|
||||
|
||||
export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
|
||||
{
|
||||
param: 'name',
|
||||
label: 'Município',
|
||||
staffOnly: false,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'region',
|
||||
label: 'Território',
|
||||
staffOnly: false,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'kind',
|
||||
label: 'Tipo',
|
||||
allLabel: 'Todos',
|
||||
staffOnly: false,
|
||||
selection: 'single',
|
||||
options: (
|
||||
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
|
||||
).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
|
||||
},
|
||||
{
|
||||
param: 'advisor',
|
||||
label: 'Assessores',
|
||||
staffOnly: true,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'coverage',
|
||||
label: 'Assessoria',
|
||||
staffOnly: true,
|
||||
selection: 'toggle',
|
||||
options: (
|
||||
Object.keys(municipalityListCoverageLabels) as Array<
|
||||
keyof typeof municipalityListCoverageLabels
|
||||
>
|
||||
).map((coverage) => ({ value: coverage, label: municipalityListCoverageLabels[coverage] })),
|
||||
},
|
||||
{
|
||||
param: 'trend',
|
||||
label: 'Tendência',
|
||||
staffOnly: true,
|
||||
selection: 'multi',
|
||||
options: (Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
|
||||
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const municipalityFilterDefinitionByParam = Object.fromEntries(
|
||||
municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
|
||||
) as Record<MunicipalityFilterParam, MunicipalityFilterDefinition>
|
||||
|
||||
export const getMunicipalityFilterDefinition = (
|
||||
param: MunicipalityFilterParam,
|
||||
): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
|
||||
|
||||
export const getMunicipalitySingleFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: 'kind' | 'coverage',
|
||||
): string | undefined => state[param]
|
||||
|
||||
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
|
||||
|
||||
export const getMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'region') return state.regions ?? []
|
||||
if (param === 'slug') return state.slugs ?? []
|
||||
if (param === 'trend') return state.trends ?? []
|
||||
return (state.advisors ?? []).map(String)
|
||||
}
|
||||
|
||||
const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
|
||||
parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
|
||||
|
||||
/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
|
||||
export const applyMunicipalityKindFilter = (
|
||||
state: MunicipalityListState,
|
||||
value: string | undefined,
|
||||
): MunicipalityListState =>
|
||||
parseMunicipalityListParams({
|
||||
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
kind: value || undefined,
|
||||
})
|
||||
|
||||
/** Toggle exclusivity: clicking the active value clears (coverage). */
|
||||
export const toggleMunicipalityExclusiveFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: 'coverage',
|
||||
value: string,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({
|
||||
...state,
|
||||
[param]: state[param] === value ? undefined : (value as MunicipalityListState['coverage']),
|
||||
})
|
||||
|
||||
export const toggleMunicipalityPriorityFilter = (
|
||||
state: MunicipalityListState,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({
|
||||
...state,
|
||||
priority: state.priority === 'alta' ? undefined : 'alta',
|
||||
})
|
||||
|
||||
/**
|
||||
* Writes the RAW param and lets `parseMunicipalityListParams` validate, so no
|
||||
* branch has to cast a `string[]` into the state's narrow value types.
|
||||
*/
|
||||
const setMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
values: string[],
|
||||
): MunicipalityListState =>
|
||||
parseMunicipalityListParams({
|
||||
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
[param]: values,
|
||||
})
|
||||
|
||||
export const toggleMunicipalityMultiFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
value: string,
|
||||
): MunicipalityListState => {
|
||||
const current = getMunicipalityMultiFilterValues(state, param)
|
||||
return setMunicipalityMultiFilterValues(
|
||||
state,
|
||||
param,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
export const clearMunicipalityMultiFilter = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityMultiFilterParam,
|
||||
): MunicipalityListState => setMunicipalityMultiFilterValues(state, param, [])
|
||||
|
||||
/** Clear municipality-column filters (priority + selected slugs). */
|
||||
export const clearMunicipalityNameFilters = (state: MunicipalityListState): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({ ...state, priority: undefined, slugs: undefined })
|
||||
|
||||
/** Clear advisor-column filters (com/sem assessor + selected advisors). */
|
||||
export const clearMunicipalityAdvisorFilters = (
|
||||
state: MunicipalityListState,
|
||||
): MunicipalityListState =>
|
||||
withMunicipalityListPageReset({ ...state, coverage: undefined, advisors: undefined })
|
||||
|
||||
export const buildMunicipalityFilterHref = (next: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref(next, 1)
|
||||
|
||||
export const isMunicipalityColumnFilterActive = (
|
||||
state: MunicipalityListState,
|
||||
param: MunicipalityFilterParam,
|
||||
): boolean => {
|
||||
switch (param) {
|
||||
case 'name':
|
||||
return Boolean(state.priority || state.slugs?.length)
|
||||
case 'region':
|
||||
return Boolean(state.regions?.length)
|
||||
case 'advisor':
|
||||
// The Assessores popover also owns the com/sem assessor toggle.
|
||||
return Boolean(state.advisors?.length || state.coverage)
|
||||
case 'kind':
|
||||
return Boolean(state.kind)
|
||||
case 'coverage':
|
||||
return Boolean(state.coverage)
|
||||
case 'trend':
|
||||
return Boolean(state.trends?.length)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
|
||||
const firstNamesLabel = (names: string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const formatMunicipalityActiveFiltersSummary = (
|
||||
state: MunicipalityListState,
|
||||
): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.priority) parts.push(municipalityPriorityLabels.alta)
|
||||
if (state.slugs?.length) {
|
||||
parts.push(firstNamesLabel(state.slugs.map((slug) => municipalityNameBySlug.get(slug) ?? slug)))
|
||||
}
|
||||
if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
|
||||
if (state.kind) parts.push(municipalityKindLabels[state.kind])
|
||||
if (state.advisors?.length) {
|
||||
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
|
||||
}
|
||||
if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
|
||||
if (state.trends?.length) {
|
||||
parts.push(
|
||||
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
|
||||
|
||||
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
|
||||
const summary = formatMunicipalityActiveFiltersSummary(state)
|
||||
if (!summary) return null
|
||||
|
||||
const label = `Municípios · ${summary}`
|
||||
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
|
||||
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
|
||||
}
|
||||
|
||||
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref(state, 1)
|
||||
|
||||
export const getCampaignScopeLabel = (
|
||||
role: CampaignUser['role'],
|
||||
municipalityCount: number,
|
||||
): string => {
|
||||
if (role === 'advisor') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
|
||||
}
|
||||
if (role === 'leader') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
|
||||
}
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
|
||||
}
|
||||
|
||||
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
|
||||
export const formatMunicipalityGeographyLabel = (municipality: {
|
||||
region: string
|
||||
kind: Municipality['kind']
|
||||
zoneNumber?: number | null
|
||||
}): string =>
|
||||
municipality.kind === 'zona' && municipality.zoneNumber != null
|
||||
? `${municipality.region} · ZE ${municipality.zoneNumber}`
|
||||
: municipality.region
|
||||
|
|
@ -25,7 +25,7 @@ import {
|
|||
parseMunicipalityListParams,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ import {
|
|||
buildMunicipalityListWhere,
|
||||
municipalityPageSize,
|
||||
parseMunicipalityListParams,
|
||||
resolveMunicipalityLastSignalAt,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListState,
|
||||
type MunicipalityListSortDirection,
|
||||
type MunicipalityListSortKey,
|
||||
} from '@/utilities/municipalityUi'
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
|
||||
import {
|
||||
municipalityListSelect,
|
||||
toMunicipalityDetailViewModel,
|
||||
|
|
@ -101,11 +101,12 @@ const loadMunicipalityListFilterFacets = async (
|
|||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: MunicipalityListState,
|
||||
loadedScope: { where: Where; rows: MunicipalityFacetRow[] } | null,
|
||||
/** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */
|
||||
loadedScope: { where: Where; rows: Promise<MunicipalityFacetRow[]> } | null,
|
||||
): Promise<MunicipalityListFilterFacets> => {
|
||||
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
|
||||
if (loadedScope) {
|
||||
rowsByWhere.set(JSON.stringify(loadedScope.where), Promise.resolve(loadedScope.rows))
|
||||
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows)
|
||||
}
|
||||
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
|
||||
const where = buildMunicipalityListWhere({ ...state, ...omit })
|
||||
|
|
@ -291,7 +292,12 @@ export const loadMunicipalityListPageBundle = async (
|
|||
}),
|
||||
})
|
||||
|
||||
const [listResult, scopeCount, staffScope] = await Promise.all([
|
||||
// Request-scoped shared load (docs + pledge aggregates in one place).
|
||||
const staffScopePromise = isStaff
|
||||
? loadMunicipalityScope(payload, user, where)
|
||||
: Promise.resolve(null)
|
||||
|
||||
const [listResult, scopeCount, staffScope, filterFacets] = await Promise.all([
|
||||
listQuery,
|
||||
payload.count({
|
||||
collection: 'municipality',
|
||||
|
|
@ -299,17 +305,19 @@ export const loadMunicipalityListPageBundle = async (
|
|||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
// Request-scoped shared load (docs + pledge aggregates in one place).
|
||||
isStaff ? loadMunicipalityScope(payload, user, where) : Promise.resolve(null),
|
||||
staffScopePromise,
|
||||
// Joins the same round of reads: its seed key derives from `where`
|
||||
// synchronously, and the scope rows arrive as a promise (B16+).
|
||||
loadMunicipalityListFilterFacets(
|
||||
payload,
|
||||
user,
|
||||
state,
|
||||
isStaff
|
||||
? { where, rows: staffScopePromise.then((scope) => scope?.municipalities ?? []) }
|
||||
: null,
|
||||
),
|
||||
])
|
||||
|
||||
const filterFacets = await loadMunicipalityListFilterFacets(
|
||||
payload,
|
||||
user,
|
||||
state,
|
||||
staffScope ? { where, rows: staffScope.municipalities } : null,
|
||||
)
|
||||
|
||||
let overview: MunicipalityListOverviewData | null = null
|
||||
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
|
||||
let goalCoverageByMunicipalityID = new Map<
|
||||
|
|
|
|||
49
src/utilities/municipalitySignal.ts
Normal file
49
src/utilities/municipalitySignal.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* E9 "frescor" — signal freshness for the allocation queue. One rule shared by
|
||||
* the server ordering (`municipalityPageData.ts`) and the list cell, so
|
||||
* "há N dias" never disagrees with the position in the queue. Split out of the
|
||||
* former `municipalityUi.ts` in Pass 2 W1.
|
||||
*/
|
||||
import { latestIsoTimestamp } from '@/utilities/campaignTime'
|
||||
|
||||
/**
|
||||
* The last time ANYBODY recorded something here: a staff update or a
|
||||
* leadership pledge declaration/estimate.
|
||||
*/
|
||||
export const resolveMunicipalityLastSignalAt = (
|
||||
lastUpdateAt: string | null,
|
||||
lastPledgeAt: string | null,
|
||||
): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
|
||||
|
||||
/**
|
||||
* Days since the last signal, floored. The research report only says a
|
||||
* commitment left untouched "for weeks" is worth less, so the threshold below
|
||||
* is a convention (3 weeks), not a measured decay curve.
|
||||
*/
|
||||
export const MUNICIPALITY_COLD_SIGNAL_DAYS = 21
|
||||
|
||||
export const municipalitySignalAgeInDays = (
|
||||
lastSignalAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): number | null => {
|
||||
if (!lastSignalAt) return null
|
||||
const elapsed = now.getTime() - new Date(lastSignalAt).getTime()
|
||||
if (Number.isNaN(elapsed)) return null
|
||||
return Math.max(0, Math.floor(elapsed / 86_400_000))
|
||||
}
|
||||
|
||||
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
|
||||
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
|
||||
|
||||
/**
|
||||
* "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
|
||||
* Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
|
||||
* "anteontem" and minute/hour granularity, which breaks both the day-based
|
||||
* cold threshold and the tabular-nums scan down the column.
|
||||
*/
|
||||
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
|
||||
if (ageInDays === null) return 'Sem sinal'
|
||||
if (ageInDays === 0) return 'hoje'
|
||||
if (ageInDays === 1) return 'há 1 dia'
|
||||
return `há ${ageInDays} dias`
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -1,31 +1,37 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
buildMunicipalityListHref,
|
||||
buildMunicipalityListWhere,
|
||||
buildMunicipalitySortHref,
|
||||
buildMunicipalityFilterOptionHref,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
formatMunicipalityConcentrationHint,
|
||||
formatMunicipalityListSortSummary,
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalityColumnFilterActive,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalityListSortLabels,
|
||||
municipalitySignalAgeInDays,
|
||||
parseMunicipalityListParams,
|
||||
parseMunicipalitySortValue,
|
||||
resolveMunicipalityLastSignalAt,
|
||||
resolveMunicipalityListSort,
|
||||
serializeMunicipalitySortValue,
|
||||
shouldUpdateMunicipalitySearchUrl,
|
||||
toggleMunicipalityExclusiveFilterValue,
|
||||
toggleMunicipalityMultiFilterValue,
|
||||
toggleMunicipalityPriorityFilter,
|
||||
} from '@/utilities/municipalityUi'
|
||||
type MunicipalityMultiFilterParam,
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
buildMunicipalityListWhere,
|
||||
buildMunicipalitySortHref,
|
||||
formatMunicipalityListSortSummary,
|
||||
municipalityListSortLabels,
|
||||
parseMunicipalityListParams,
|
||||
parseMunicipalitySortValue,
|
||||
resolveMunicipalityListSort,
|
||||
serializeMunicipalitySortValue,
|
||||
shouldUpdateMunicipalitySearchUrl,
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import {
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalitySignalAgeInDays,
|
||||
resolveMunicipalityLastSignalAt,
|
||||
} from '@/utilities/municipalitySignal'
|
||||
|
||||
describe('shouldUpdateMunicipalitySearchUrl', () => {
|
||||
it('returns false when canonical q matches the current URL q', () => {
|
||||
|
|
@ -280,3 +286,64 @@ describe('E9 frescor do sinal', () => {
|
|||
expect(formatMunicipalitySignalAgeLabel(30)).toBe('há 30 dias')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
|
||||
const [firstSlug, secondSlug, thirdSlug] = municipalityCatalog.map((entry) => entry.slug)
|
||||
|
||||
const cases: Array<{
|
||||
name: string
|
||||
state: Parameters<typeof buildMunicipalityFilterOptionHref>[0]
|
||||
param: MunicipalityMultiFilterParam
|
||||
value: string
|
||||
}> = [
|
||||
{ name: 'adds a slug', state: parseMunicipalityListParams({}), param: 'slug', value: firstSlug! },
|
||||
{
|
||||
name: 'removes a selected slug',
|
||||
state: parseMunicipalityListParams({ slug: [firstSlug!, secondSlug!] }),
|
||||
param: 'slug',
|
||||
value: secondSlug!,
|
||||
},
|
||||
{
|
||||
name: 'keeps other filters and resets the page',
|
||||
state: parseMunicipalityListParams({
|
||||
q: 'santa',
|
||||
page: '3',
|
||||
sort: 'votos',
|
||||
slug: [thirdSlug!],
|
||||
priority: 'alta',
|
||||
}),
|
||||
param: 'slug',
|
||||
value: firstSlug!,
|
||||
},
|
||||
{
|
||||
name: 'sorts advisor ids numerically',
|
||||
state: parseMunicipalityListParams({ advisor: ['20'] }),
|
||||
param: 'advisor',
|
||||
value: '5',
|
||||
},
|
||||
{
|
||||
name: 'adds a region',
|
||||
state: parseMunicipalityListParams({ region: ['Chapada Diamantina'] }),
|
||||
param: 'region',
|
||||
value: 'Sertão do São Francisco',
|
||||
},
|
||||
{
|
||||
name: 'collapses the full trend set to "todas"',
|
||||
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
|
||||
param: 'trend',
|
||||
value: 'desfavoravel',
|
||||
},
|
||||
{
|
||||
name: 'removes a selected trend',
|
||||
state: parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] }),
|
||||
param: 'trend',
|
||||
value: 'neutra',
|
||||
},
|
||||
]
|
||||
|
||||
it.each(cases)('matches the canonical toggle+href path: $name', ({ state, param, value }) => {
|
||||
expect(buildMunicipalityFilterOptionHref(state, param, value)).toBe(
|
||||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, param, value)),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue