teqo/src/components/campaign/MunicipalityList.tsx
Francisco Ignácio Fontoura Solla 31fcc20ffd E9 /simplify: consolidate duplicated helpers, dead passthrough, comment drift
Cross-checked by parallel code-quality/performance/reuse reviewers against
the E9 commit. Behavior-preserving:

- laterTimestamp/resolveMunicipalityLastSignalAt were the same "latest of
  two ISO timestamps" written twice; both now delegate to a new
  latestIsoTimestamp in campaignTime.ts, which documents the fixed-width
  UTC invariant that makes the string compare sound.
- Hoisted the frescor comparator's Date.now() out of the per-comparison
  accessor so the sort key stops drifting mid-sort.
- Deleted the unread baseTotal/growthFactor/belowBase passthrough from the
  statewide goal entry point and fixed a comment citing a symbol
  (goalDecompositionBelowBase) that never existed; renamed
  computeStatewideGoalDecomposition -> computeStatewideSuggestedGoals now
  that nothing is proportionally decomposed.
- Collapsed the advisor badge's two copies (desktop table vs mobile card)
  into one AdvisorStatusBadge; they had quietly diverged on which icon
  renders for the "no advisor" state.
- Renamed the sort-menu's stale "Cobertura" label for advisor coverage to
  "Assessoria" (E8 already renamed the list column for the same reason);
  derived the sort-key set from the label record instead of a third
  parallel list.
- Consolidated three zeroed-scenario-record literals into one
  zeroByVoteEstimateScenario() factory in voteEstimate.ts.
- Routed three separate votesByYear[2022] reads through one votesInYear
  helper in municipalityPotential.ts.
- Gated the goal card's empty-state copy on !usesMesaEstimate so it stops
  telling a município with an existing mesa estimate to enter one.
- Swapped a test assertion pinned to a raw Tailwind token for a stable
  data-signal="cold" hook, and tightened the "desert" test's bound to
  check against the actual growth factor (the old bound was true for
  every slug regardless of the formula).

Left alone (roadmap decision, not cleanup): sanityCheckSuggestedGoalsByTerritory
has no production caller yet (deferred to E11/E12); comment shortened
instead of deleting the dead-but-documented code.

Verified: tsc --noEmit, pnpm lint (0 warnings), 337 unit + 366 integration
tests, pnpm build all green.
2026-07-25 02:24:18 -03:00

448 lines
18 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.

import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
import Link from 'next/link'
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/MunicipalityListGoalCoverageCell'
import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
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 {
formatMunicipalityConcentrationHint,
formatMunicipalityGeographyLabel,
formatMunicipalityListSortSummary,
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalityKindLabels,
municipalityListCoverageLabels,
municipalityPriorityLabels,
municipalitySignalAgeInDays,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
MunicipalityListViewModel,
} from '@/utilities/municipalityViewModels'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
} from '@/utilities/voteEstimate'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
export type MunicipalityListProps = {
municipalities: MunicipalityListViewModel[]
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
isStaffView: boolean
isCoordinator: boolean
advisorOptions: EligibleAdvisorOption[]
trendFormAction: MunicipalityStaffFormAction
advisorsFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
}
const VotePositionReadout = ({
position,
layout,
}: {
position: NonNullable<MunicipalityListViewModel['votePosition2022']>
layout: 'table' | 'card'
}) => {
const share = formatMunicipalityVoteShare(position.share)
const rank = formatMunicipalityVoteRank(position.rank)
const metaLine = `${formatElectionNumber(position.votes)} · ${rank}`
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
return (
<div
className={cn(
'flex flex-col gap-0.5 tabular-nums',
layout === 'table' ? 'items-end text-right' : 'text-sm',
)}
aria-label={ariaLabel}
>
<span className={cn('font-medium', layout === 'card' && 'text-foreground')}>{share}</span>
<span className="text-xs text-muted-foreground">{metaLine}</span>
</div>
)
}
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
* leadership pledge).
*
* Cold reads in the warning amber, NOT destructive: early in the campaign
* most municípios are past the 21-day threshold, and painting them all red
* would drown the one state that really is an error in this list — a priority
* município with nobody answering for it. Same reason it stays text with an
* icon instead of a third badge: the row already carries the priority and
* assessoria pills.
*/
const SignalAgeReadout = ({
lastSignalAt,
layout,
}: {
lastSignalAt: string | null
layout: 'table' | 'card'
}) => {
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
const isCold = isMunicipalitySignalCold(ageInDays)
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
return (
<div
data-signal={isCold ? 'cold' : 'fresh'}
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
title={
isCold
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
: undefined
}
>
<span
className={cn(
'inline-flex items-center gap-1 tabular-nums',
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
)}
>
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{ageLabel}
</span>
{dateLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
) : null}
</div>
)
}
/**
* E9: a priority município with nobody answering for it is the queue's
* loudest row, so it reads "Sem responsável" in destructive — the same words
* the overview's coluna da vergonha uses to count them. Non-priority ones
* keep the softer pending tone: they are a gap, not a fire.
*/
const AdvisorStatusBadge = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
if (municipality.advisorIDs.length > 0) {
return (
<Badge variant="estimate-confirmed">
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
Coberta
</Badge>
)
}
const isPriority = municipality.priority === 'alta'
return (
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
</Badge>
)
}
const advisorEntries = (
municipality: MunicipalityListViewModel,
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
) =>
municipality.advisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
const advisorNames = (
municipality: MunicipalityListViewModel,
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
export const MunicipalityList = ({
municipalities,
advisorNamesById,
isStaffView,
isCoordinator,
advisorOptions,
trendFormAction,
advisorsFormAction,
state,
}: MunicipalityListProps) => {
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const concentrationHint = formatMunicipalityConcentrationHint()
const signalHint = `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`
// 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.`
return (
<>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.map((municipality) => {
const names = advisorNames(municipality, advisorNamesById)
const position = municipality.votePosition2022
return (
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">{municipality.name}</h3>
<p className="text-sm text-muted-foreground">
{formatMunicipalityGeographyLabel(municipality)}
</p>
{position ? <VotePositionReadout position={position} layout="card" /> : null}
</div>
{municipality.priority === 'alta' && isStaffView ? (
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd className="flex justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
formAction={trendFormAction}
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Cobertura da meta</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessoria</dt>
<dd>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
currentAdvisorIDs={municipality.advisorIDs}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : names.length ? (
names.join(', ')
) : (
<AdvisorStatusBadge municipality={municipality} />
)}
</dd>
</div>
</dl>
) : null}
<Button asChild variant="outline" className="min-h-11 w-full">
<Link href={`/campanha/municipios/${municipality.slug}`}>Abrir município</Link>
</Button>
</article>
)
})}
</div>
<div data-view="desktop-table" className="hidden overflow-hidden rounded-xl border md:block">
<Table>
<TableCaption className="sr-only">
{sortSummary}. Coluna 2022: {concentrationHint}
</TableCaption>
<TableHeader>
<TableRow>
<MunicipalitySortableHead state={state} sortKey="name">
Município
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="region">
Território de identidade
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="kind">
Tipo
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
tooltip={concentrationHint}
/>
{isStaffView ? (
<>
<TableHead>Assessores</TableHead>
<MunicipalitySortableHead state={state} sortKey="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="coverage">
Assessoria
</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.map((municipality) => {
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>
{municipality.priority === 'alta' && 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}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : (
<MunicipalityAdvisorAvatarStack
advisors={advisorEntries(municipality, advisorNamesById)}
/>
)}
</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>
<AdvisorStatusBadge municipality={municipality} />
</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>
</>
)
}