teqo/src/utilities/municipalityListUrl.ts
Francisco Ignácio Fontoura Solla ed0fb4065f B17 simplify: inherit header labels, split persist from refresh.
The four *SortableHead islands already fall back to their sort-label record,
so B17's new `label` made the header text a third copy instead of removing the
second one — 16 redundant `children` deleted, leaving only `frescor`, which
diverges on purpose and now reads the record too.

The 3 s "safety net" timer was committing like a close, refresh included, which
recreated the per-toggle route render the 400 ms debounce was rejected for: at a
reading pace every checkbox landed outside the window. The timer now writes the
cookie only; closing repaints. A session that ends where it started refreshes
nothing, and a menu open when the route unmounts saves from the cleanup, since
Radix reports no close event for a sidebar navigation. Both pinned red first.

Also: `columnVisibility` required rather than optional for the tests' benefit,
the redundant per-list cap dropped, `resolveVisibleColumns` no longer copies,
and B29's unused `LeadershipListAccessFilter` export un-exported — knip was
failing on main too.
2026-07-28 09:17:46 -03:00

536 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { engagementLevels, type EngagementLevel } from '@/lib/engagementLevel'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import {
politicalTrendLabels,
territorialClassLabels,
type MunicipalityListColumnId,
type PoliticalTrendStatus,
} from '@/utilities/municipalityLabels'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import type { Municipality } from '@/payload-types'
export const municipalityPageSize = 25
export type MunicipalityListSortKey =
| 'name'
| 'region'
| 'kind'
| 'trend'
| 'expectedVotes'
| 'lastUpdateAt'
| 'coverage'
| 'votos'
| 'deficit'
| 'frescor'
| 'classe'
| 'nivel'
export type MunicipalityListSortDirection = 'asc' | 'desc'
/**
* E14 filter values: the five levels plus the absence of one. "Sem nível" is
* a real answer here — it is the triage queue — so it is a selectable value
* and not the empty selection.
*
* Same sentinel convention as `NO_PARTY_FILTER_VALUE` in `stateDeputyListUrl`
* (second call site; a third should extract the pair sentinel + `exists: false`
* branch). Unlike `party`, the level is a closed enum, so it also gets the
* "all selected → absent" canonicalization of `parseExhaustiveEnumParam`.
*/
export const NO_LEVEL_FILTER_VALUE = 'sem_nivel'
export type MunicipalityListLevelFilterValue = EngagementLevel | typeof NO_LEVEL_FILTER_VALUE
export const municipalityListLevelFilterValues: readonly MunicipalityListLevelFilterValue[] = [
...engagementLevels,
NO_LEVEL_FILTER_VALUE,
]
/**
* E9 allocation queue: the list opens on the decision it exists to serve —
* where the goal is least covered by auditable commitments (biggest deficit
* first), in the `central` scenario. Since E9 anchored the suggested goal on
* the candidate's own 2022 vote, this ordering stays close to the previous
* `votos` default instead of surfacing deserts with inflated goals.
*/
const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
name: 'Município',
region: 'Território',
kind: 'Tipo',
trend: 'Tendência',
expectedVotes: 'Votos estimados',
lastUpdateAt: 'Última atualização',
/** Sorts the "Assessores" column — "Cobertura" alone now reads as the goal one (`deficit`). */
coverage: 'Assessores',
/** Short header — definition lives on hover (`formatMunicipalityConcentrationHint`). */
votos: '2022',
deficit: 'Cobertura da meta',
frescor: 'Frescor do sinal',
classe: 'Classe',
nivel: 'Nível',
}
/**
* B17 — the name each column answers to in the column picker. Ten of the
* twelve quote the sort label, and the header renders that same record
* (`MunicipalitySortableHead` falls back to it when given no children, which
* is why those heads pass none), so renaming a header renames the menu entry
* with it. The two that differ do so on purpose: a header sits above its own
* data and can be telegraphic, while the same word alone in a list of column
* names says nothing. `lastSignal` is read back by the header it names.
*/
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
name: municipalityListSortLabels.name,
region: municipalityListSortLabels.region,
kind: municipalityListSortLabels.kind,
/** Header is the bare year, under the "2022" group of the table. */
votos: 'Votação 2022',
classe: municipalityListSortLabels.classe,
level: municipalityListSortLabels.nivel,
advisors: municipalityListSortLabels.coverage,
trend: municipalityListSortLabels.trend,
expectedVotes: municipalityListSortLabels.expectedVotes,
/** The column shows the signal; `frescor` sorts by how old it is. */
lastSignal: 'Último sinal',
goalCoverage: municipalityListSortLabels.deficit,
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
}
export type MunicipalityListState = {
page: number
q?: string
/** Multi-select (OR) identity territories. */
regions?: BahiaIdentityTerritory[]
/** Multi-select (OR) catalog slugs. */
slugs?: string[]
/** Multi-select (OR) campaignUser advisor IDs. */
advisors?: number[]
kind?: Municipality['kind']
coverage?: 'com_assessor' | 'sem_assessor'
priority?: 'alta'
/**
* Multi-select (OR) political trends. Never holds the full set: "todas" is
* encoded as absent, canonicalized by `parseMunicipalityListParams`.
*/
trends?: PoliticalTrendStatus[]
/**
* Multi-select (OR) E10 territorial classes. Derived from the committed TSE
* artifact, so — unlike every filter above — it is NOT part of
* `buildMunicipalityListWhere`: `municipalityPageData` applies it in memory.
* Never holds the full set (same "todas" canonicalization as `trends`).
*/
classes?: MunicipalityTerritorialClass[]
/**
* Multi-select (OR) E14 engagement levels, including "sem nível". Stored,
* so unlike `classes` this one IS part of `buildMunicipalityListWhere`.
* Never holds the full set (same "todos" canonicalization as `trends`).
*/
levels?: MunicipalityListLevelFilterValue[]
/** Candidate number for the map comparison mode (does not filter the list). */
compare?: number
sort?: MunicipalityListSortKey
dir?: MunicipalityListSortDirection
}
export type MunicipalityListSearchParams = CampaignListRawSearchParams
const municipalityListParamNames = [
'q',
'region',
'slug',
'advisor',
'kind',
'coverage',
'priority',
'trend',
'class',
'level',
'compare',
'sort',
'dir',
'page',
] as const
const municipalityListParamNameSet = new Set<string>(municipalityListParamNames)
// Derived from the label record (total over the union), so a new sort key is
// declared in the type and the labels only.
const municipalityListSortKeySet = new Set<string>(Object.keys(municipalityListSortLabels))
const municipalityListSortDirSet = new Set<MunicipalityListSortDirection>(['asc', 'desc'])
const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
'expectedVotes',
'lastUpdateAt',
'votos',
// Both open on the worst case: biggest uncovered deficit, coldest signal.
'deficit',
'frescor',
// Ordinal, not alphabetical: descending means reduto first.
'classe',
// Ordinal too: descending opens on N4, where the campaign is most invested.
'nivel',
]
export const defaultMunicipalityListSortDir = (
key: MunicipalityListSortKey,
): MunicipalityListSortDirection => (sortKeysWithDescDefault.includes(key) ? 'desc' : 'asc')
export const resolveMunicipalityListSort = (
state: MunicipalityListState,
): { sort: MunicipalityListSortKey; dir: MunicipalityListSortDirection } => {
const sort = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const dir = state.dir ?? defaultMunicipalityListSortDir(sort)
return { sort, dir }
}
export const formatMunicipalityListSortSummary = (
sort: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): string => {
if (sort === 'votos') {
return dir === 'desc' ? 'Ordenado por 2022 ↓' : 'Ordenado por 2022 ↑'
}
if (sort === 'name') {
return dir === 'asc' ? 'Ordenado por nome (A–Z)' : 'Ordenado por nome (Z–A)'
}
if (sort === 'deficit') {
return dir === 'desc'
? 'Ordenado por déficit da meta (maior primeiro)'
: 'Ordenado por déficit da meta (menor primeiro)'
}
if (sort === 'frescor') {
return dir === 'desc'
? 'Ordenado por frescor (sinal mais frio primeiro)'
: 'Ordenado por frescor (sinal mais recente primeiro)'
}
if (sort === 'classe') {
return dir === 'desc'
? 'Ordenado por classe (reduto primeiro)'
: 'Ordenado por classe (marginal primeiro)'
}
if (sort === 'nivel') {
return dir === 'desc' ? 'Ordenado por nível (N4 primeiro)' : 'Ordenado por nível (N0 primeiro)'
}
const label = municipalityListSortLabels[sort]
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
const parseRegionsParam = (raw: string | string[] | undefined): BahiaIdentityTerritory[] => {
const regions: BahiaIdentityTerritory[] = []
const seen = new Set<BahiaIdentityTerritory>()
for (const token of allParamValues(raw)) {
const region = canonicalTerritoryBySearchValue.get(normalizeSearchPhrase(token))
if (!region || seen.has(region)) continue
seen.add(region)
regions.push(region)
}
return regions
}
const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
const slugs: string[] = []
const seen = new Set<string>()
for (const token of allParamValues(raw)) {
if (!isMunicipalitySlug(token) || seen.has(token)) continue
seen.add(token)
slugs.push(token)
}
return slugs
}
const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
const advisors: number[] = []
const seen = new Set<number>()
for (const token of allParamValues(raw)) {
const id = strictDecimalInteger(token)
if (id === undefined || seen.has(id)) continue
seen.add(id)
advisors.push(id)
}
return advisors.sort((left, right) => left - right)
}
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
const engagementLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
export const municipalityListStateToRawParams = (
state: MunicipalityListState,
page = state.page,
): MunicipalityListSearchParams => ({
page: String(page),
q: state.q,
region: state.regions,
slug: state.slugs,
advisor: state.advisors?.map(String),
kind: state.kind,
coverage: state.coverage,
priority: state.priority,
trend: state.trends,
class: state.classes,
level: state.levels,
compare: state.compare === undefined ? undefined : String(state.compare),
sort: state.sort,
dir: state.dir,
})
export const parseMunicipalityListParams = (
params: MunicipalityListSearchParams,
): MunicipalityListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const regions = parseRegionsParam(params.region)
const slugs = parseSlugsParam(params.slug)
const advisors = parseAdvisorsParam(params.advisor)
const rawKind = firstValue(params.kind)
const rawCoverage = firstValue(params.coverage)
const rawPriority = firstValue(params.priority)
const trends = parseExhaustiveEnumParam<PoliticalTrendStatus>(
params.trend,
politicalTrendStatusSet,
)
const classes = parseExhaustiveEnumParam<MunicipalityTerritorialClass>(
params.class,
territorialClassSet,
)
const levels = parseExhaustiveEnumParam<MunicipalityListLevelFilterValue>(
params.level,
engagementLevelFilterSet,
)
const rawCompare = strictDecimalInteger(firstValue(params.compare))
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
const rawDir = firstValue(params.dir) as MunicipalityListSortDirection | undefined
const dir = rawDir && municipalityListSortDirSet.has(rawDir) ? rawDir : undefined
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(regions.length ? { regions } : {}),
...(slugs.length ? { slugs } : {}),
...(advisors.length ? { advisors } : {}),
...(rawKind === 'municipio' || rawKind === 'zona' ? { kind: rawKind } : {}),
...(rawCoverage === 'com_assessor' || rawCoverage === 'sem_assessor'
? { coverage: rawCoverage }
: {}),
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
...(trends.length ? { trends } : {}),
...(classes.length ? { classes } : {}),
...(levels.length ? { levels } : {}),
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
}
export const buildMunicipalityListWhere = (state: MunicipalityListState): Where => {
const filters: Where[] = []
const searchedZone = strictDecimalInteger(state.q)
if (state.q) {
const searchFilters: Where[] = [{ name: { contains: state.q } }]
if (searchedZone && searchedZone <= 999) {
searchFilters.push({ zoneNumber: { equals: searchedZone } })
}
filters.push({ or: searchFilters })
}
if (state.regions?.length) filters.push({ region: { in: state.regions } })
if (state.slugs?.length) filters.push({ slug: { in: state.slugs } })
if (state.advisors?.length) filters.push({ advisors: { in: state.advisors } })
if (state.kind) filters.push({ kind: { equals: state.kind } })
if (state.coverage) {
filters.push({
advisors: { exists: state.coverage === 'com_assessor' },
})
}
if (state.priority) filters.push({ priority: { equals: state.priority } })
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
if (state.levels?.length) {
// "Sem nível" is absence, which no `in` can express — it rides along as an
// OR branch so "N0 ou sem nível" (the triage view) is one query.
const selectedLevels = state.levels.filter(
(level): level is EngagementLevel => level !== NO_LEVEL_FILTER_VALUE,
)
const levelFilters: Where[] = []
if (selectedLevels.length) levelFilters.push({ engagementLevel: { in: selectedLevels } })
if (state.levels.includes(NO_LEVEL_FILTER_VALUE)) {
levelFilters.push({ engagementLevel: { exists: false } })
}
const [onlyFilter, ...restFilters] = levelFilters
filters.push(onlyFilter && restFilters.length === 0 ? onlyFilter : { or: levelFilters })
}
// `state.classes` is absent on purpose: the class is derived from the TSE
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
// filters it in memory over the unpaginated scope.
return filters.length ? { and: filters } : {}
}
/**
* 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 params = new URLSearchParams()
const resolvedSort = canonicalState.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const resolvedDir = canonicalState.dir ?? defaultMunicipalityListSortDir(resolvedSort)
const isListDefault =
resolvedSort === DEFAULT_MUNICIPALITY_LIST_SORT_KEY &&
resolvedDir === defaultMunicipalityListSortDir(DEFAULT_MUNICIPALITY_LIST_SORT_KEY)
if (canonicalState.q) params.set('q', canonicalState.q)
for (const region of canonicalState.regions ?? []) params.append('region', region)
for (const slug of canonicalState.slugs ?? []) params.append('slug', slug)
for (const advisor of canonicalState.advisors ?? []) params.append('advisor', String(advisor))
if (canonicalState.kind) params.set('kind', canonicalState.kind)
if (canonicalState.coverage) params.set('coverage', canonicalState.coverage)
if (canonicalState.priority) params.set('priority', canonicalState.priority)
for (const trend of canonicalState.trends ?? []) params.append('trend', trend)
for (const territorialClass of canonicalState.classes ?? []) {
params.append('class', territorialClass)
}
for (const level of canonicalState.levels ?? []) params.append('level', level)
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
if (!isListDefault) {
params.set('sort', resolvedSort)
if (resolvedDir !== defaultMunicipalityListSortDir(resolvedSort)) {
params.set('dir', resolvedDir)
}
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildMunicipalityListSearchParams = (
state: MunicipalityListState,
page = state.page,
): URLSearchParams =>
serializeCanonicalMunicipalityListSearchParams(
parseMunicipalityListParams(municipalityListStateToRawParams(state, page)),
)
export const buildMunicipalityListHref = (state: MunicipalityListState, page: number): string =>
buildListHref(state, buildMunicipalityListSearchParams, '/campanha/municipios', page)
export const buildMunicipalitySortHref = (
state: MunicipalityListState,
nextKey: MunicipalityListSortKey,
): string => {
const currentSort = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const currentDir = state.dir ?? defaultMunicipalityListSortDir(currentSort)
let dir: MunicipalityListSortDirection
if (nextKey === currentSort) {
dir = currentDir === 'asc' ? 'desc' : 'asc'
} else {
dir = defaultMunicipalityListSortDir(nextKey)
}
const updatedState: MunicipalityListState = {
...state,
sort: nextKey,
dir,
page: 1,
}
return buildMunicipalityListHref(updatedState, 1)
}
const formatMunicipalitySortOptionLabel = (
key: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): string => {
const base = municipalityListSortLabels[key]
if (key === 'expectedVotes' || key === 'votos') {
return dir === 'asc' ? `${base} (menor → maior)` : `${base} (maior → menor)`
}
if (key === 'lastUpdateAt') {
return dir === 'asc' ? `${base} (mais antiga)` : `${base} (mais recente)`
}
if (key === 'deficit') {
return dir === 'asc' ? `${base} (mais coberta)` : `${base} (mais descoberta)`
}
if (key === 'frescor') {
return dir === 'asc' ? `${base} (mais recente)` : `${base} (mais frio)`
}
if (key === 'classe') {
return dir === 'asc' ? `${base} (marginal primeiro)` : `${base} (reduto primeiro)`
}
if (key === 'nivel') {
return dir === 'asc' ? `${base} (N0 primeiro)` : `${base} (N4 primeiro)`
}
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
}
export const municipalityListSortOptions = (
Object.keys(municipalityListSortLabels) as MunicipalityListSortKey[]
).flatMap((key) => [
{ key, dir: 'asc' as const, label: formatMunicipalitySortOptionLabel(key, 'asc') },
{ key, dir: 'desc' as const, label: formatMunicipalitySortOptionLabel(key, 'desc') },
])
export const serializeMunicipalitySortValue = (
key: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): string => `${key}|${dir}`
export const parseMunicipalitySortValue = (
value: string,
): { key: MunicipalityListSortKey; dir: MunicipalityListSortDirection } | null => {
const [rawKey, rawDir] = value.split('|')
if (!municipalityListSortKeySet.has(rawKey as MunicipalityListSortKey)) return null
if (!municipalityListSortDirSet.has(rawDir as MunicipalityListSortDirection)) return null
return { key: rawKey as MunicipalityListSortKey, dir: rawDir as MunicipalityListSortDirection }
}
export const resolveMunicipalityListUrl = (
params: MunicipalityListSearchParams,
totalPages?: number,
): {
state: MunicipalityListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: municipalityListParamNameSet,
parse: parseMunicipalityListParams,
buildSearchParams: buildMunicipalityListSearchParams,
basePath: '/campanha/municipios',
totalPages,
})