Merge pull request #273 from fsolla/cursor/pool-i266-0b42294b-54dc

B129 — Remover overview da lista de municípios
This commit is contained in:
Francisco Solla 2026-08-02 17:30:24 -03:00 committed by GitHub
commit 544292f8f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 81 additions and 302 deletions

View file

@ -0,0 +1,54 @@
# Impl: B129 — Remover overview da lista de municípios
Status: aprovado
Atualizado em: 2026-08-02
Issue: #266
Intenção: docs/plans/remover-overview-lista-municipios.md
Appetite restante: remoção cirúrgica (sem redesign)
## Leitura da intenção
- **Outcome:** `/campanha/municipios` mostra filtros → lista, sem strip de métricas agregadas.
- **O que NÃO negociar:** filtros + cenário na barra; overview de apoiadores e dashboard intactos; leader lockdown.
- **O que reavaliar:** manter prefetch `loadStatewideSuggestedGoals` — sim, ainda aquece cache para `loadMunicipalityGoalCoverageBundle` usado nas linhas/colunas.
## Abordagem recomendada
**Opções consideradas:**
- **A — Esconder com CSS/feature flag:** rejeitada — morto no loader e no bundle é mais honesto e barato.
- **B — Remover UI, manter `overview` no bundle:** rejeitada — viola “dead code dies immediately”.
- **C — Remover UI + tipo/campo/computação do loader:** **recomendada**.
**Rejeitadas:** A, B.
### Componentes / mudanças
- **`municipios/page.tsx`:** remover import/render de `MunicipalityListOverview` e `shameHref`.
- **`MunicipalityListOverview.tsx`:** deletar arquivo.
- **`municipalityPageData.ts`:** remover `MunicipalityListOverviewData`, campo `overview` do bundle, rollup/overview-only fields; manter `staffScope` → `pledgeAggregates` + `goalCoverageByMunicipalityID` para sort/células.
- **Tests:** remover asserts de `bundle.overview`; manter asserts de lista/filtros/classe.
- **Migration:** sem migration.
- **Access / Consent:** sem mudança.
## Fases verificáveis
1. **Server** — limpar loader + deletar componente morto.
2. **UI** — página sem overview; comentário em `MunicipalityList.tsx`.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo
- Redesenhar `loading.tsx` (fora do aceite).
- Tocar `SupporterListOverview` ou dashboard.
- **Defer:** escopo de `loadMunicipalityGoalCoverageBundle` ao page slice quando `isPagedByPayload` (gatilho: perf review pós-B129); skip `aggregateByScenario` no loader de lista (gatilho: 2º consumidor de rows-only).
## Riscos e mitigação
- **Sort por déficit depende de goal coverage:** mitigado mantendo `loadMunicipalityGoalCoverageBundle` no loader.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (int `municipalityPageData`)

View file

@ -10,7 +10,6 @@ import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitT
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListOverview } from '@/components/campaign/municipality/MunicipalityListOverview'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
@ -56,13 +55,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
const canMoveEngagementLevel = isCampaignUnrestricted(user)
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
const {
municipalities: listMunicipalities,
totalDocs,
totalPages,
overview,
filterFacets,
} = pageBundle
const { municipalities: listMunicipalities, totalDocs, totalPages, filterFacets } = pageBundle
const resolvedUrl = resolveMunicipalityListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@ -109,20 +102,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
/>
)
// E9 coluna da vergonha: same scope, filtered down to priority municipalities
// with nobody answering for them. `null` when that is already the filter.
const shameHref =
state.priority === 'alta' && state.coverage === 'sem_assessor'
? null
: buildMunicipalityListHref({ ...state, priority: 'alta', coverage: 'sem_assessor' }, 1)
// The overview and the table's filter header stay mounted even with zero
// results — only the rows are replaced by the empty state.
// The table's filter header stays mounted even with zero results — only the
// rows are replaced by the empty state.
const listBody = (
<>
{isStaffView && overview ? (
<MunicipalityListOverview view={overview} shameHref={shameHref} />
) : null}
<MunicipalityList
municipalities={listMunicipalities}
advisorNamesById={advisorNamesById}

View file

@ -95,7 +95,7 @@ export type MunicipalityListProps = {
columnVisibility: CampaignColumnVisibility
}
/** Replaces only the rows: the filter header row and the overview stay put. */
/** Replaces only the rows: the filter header row stays put. */
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
<Empty className="min-h-56">
<EmptyHeader>

View file

@ -1,177 +0,0 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useMunicipalityEstimateScenario } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignMetricStrip } from '@/components/campaign/shared/CampaignMetricStrip'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/shell/CampaignPageShell'
import { VoteEstimateScenarioStrip } from '@/components/campaign/votePledge/VoteEstimateScenarioStrip'
import { formatElectionNumber } from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import {
formatVoteEstimateEndpointsLabel,
hasAnyVoteEstimate,
voteEstimateScenarioLabels,
} from '@/lib/voteEstimate'
import {
formatGoalCoverageDeficitLabel,
formatGoalCoverageRatioLabel,
goalCoverageProgressPercent,
} from '@/utilities/municipality/goalCoverage'
import type { MunicipalityListOverviewData } from '@/utilities/municipality/municipalityPageData'
const SCENARIO_FLASH_MS = 420
/**
* E9 "coluna da vergonha": the assessoria ratio hides the only gap the
* coordination can close today, so the priority municipalities with nobody
* answering for them are named as their own number — and the number is the
* link that filters the list down to exactly those rows (`shameHref` is
* `null` when that filter is already applied, so the count doesn't pretend to
* navigate somewhere new).
*/
const AssessoriaGapDetail = ({
priorityWithoutAdvisorCount,
shameHref,
}: {
priorityWithoutAdvisorCount: number
shameHref: string | null
}) => {
if (priorityWithoutAdvisorCount === 0) {
return <p className="text-xs text-muted-foreground">Nenhuma prioritária sem responsável</p>
}
const label = `${formatElectionNumber(priorityWithoutAdvisorCount)} ${
priorityWithoutAdvisorCount === 1 ? 'prioritária' : 'prioritárias'
} sem responsável`
if (!shameHref) {
return <p className="text-xs font-medium text-destructive tabular-nums">{label}</p>
}
return (
<CampaignTransitionAnchor
href={shameHref}
scroll={false}
className="inline-flex min-h-11 items-center gap-1 text-xs font-medium text-destructive tabular-nums underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
{label}
</CampaignTransitionAnchor>
)
}
export const MunicipalityListOverview = ({
view,
shameHref,
}: {
view: MunicipalityListOverviewData
shameHref: string | null
}) => {
const { scenario } = useMunicipalityEstimateScenario()
const [isFlashing, setIsFlashing] = useState(false)
const skipFlashRef = useRef(true)
const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
if (skipFlashRef.current) {
skipFlashRef.current = false
return
}
const reduceMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduceMotion) return
setIsFlashing(true)
clearTimeout(flashTimeoutRef.current)
flashTimeoutRef.current = setTimeout(() => setIsFlashing(false), SCENARIO_FLASH_MS)
return () => clearTimeout(flashTimeoutRef.current)
}, [scenario])
const scenarioTotals = view.staffVoteTotalByScenario
const activeTotal = scenarioTotals[scenario]
const endpointsLabel = formatVoteEstimateEndpointsLabel(scenarioTotals)
const scenarioLabel = voteEstimateScenarioLabels[scenario]
const hasScenarioTotals = hasAnyVoteEstimate(scenarioTotals)
const goalCoverage = view.goalCoverageByScenario[scenario]
const heroDetail = (
<div className="flex flex-col gap-1.5">
{hasScenarioTotals ? (
<VoteEstimateScenarioStrip
values={scenarioTotals}
activeScenario={scenario}
labelMode="endpoints"
markerMode="active-only"
stretch
className="max-w-none"
/>
) : null}
{endpointsLabel ? (
<p className="text-xs text-muted-foreground tabular-nums">{endpointsLabel}</p>
) : null}
{view.missingEstimateCount > 0 ? (
<p className="text-xs text-muted-foreground tabular-nums">
{formatElectionNumber(view.missingEstimateCount)}{' '}
{view.missingEstimateCount === 1 ? 'declaração' : 'declarações'} sem estimativa
</p>
) : null}
</div>
)
return (
<section
aria-label="Visão geral dos municípios filtrados"
aria-live="polite"
className={cn(
'rounded-xl transition-[box-shadow,background-color] duration-300',
campaignPrioritySurfaceClassName,
isFlashing ? 'ring-2 ring-primary/35' : undefined,
)}
>
<CampaignMetricStrip
className="rounded-none ring-0"
metrics={[
{
label: `${scenarioLabel} nos municípios filtrados`,
value: formatElectionNumber(activeTotal),
detail: heroDetail,
emphasize: true,
valueAriaLabel: `${scenarioLabel} nos municípios filtrados: ${formatElectionNumber(activeTotal)}${
endpointsLabel ? `; ${endpointsLabel}` : ''
}${
view.missingEstimateCount > 0
? `; ${formatElectionNumber(view.missingEstimateCount)} sem estimativa`
: ''
}`,
},
{
label: 'Declarações de votos',
value: view.pledgeCount ? formatElectionNumber(view.pledgeCount) : 'Nenhuma',
},
{
label: 'Cobertura de assessoria',
value: `${view.withAdvisorCount} de ${view.municipalityCount} municípios`,
detail: (
<AssessoriaGapDetail
priorityWithoutAdvisorCount={view.priorityWithoutAdvisorCount}
shameHref={shameHref}
/>
),
progress:
view.municipalityCount > 0
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
: undefined,
},
{
label: 'Cobertura da meta',
value: formatGoalCoverageRatioLabel(goalCoverage),
detail: formatGoalCoverageDeficitLabel(goalCoverage),
progress: goalCoverageProgressPercent(goalCoverage),
},
]}
/>
</section>
)
}

View file

@ -51,33 +51,13 @@ import {
type MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
import {
rollupMunicipalityStaffVotes,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeViews'
import { type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
export const MunicipalityNotFoundError = createEntityNotFoundError(
'Municipality',
'Município não encontrado.',
)
export type MunicipalityListOverviewData = {
municipalityCount: number
staffVoteTotalByScenario: Record<VoteEstimateScenario, number>
pledgeCount: number
missingEstimateCount: number
withAdvisorCount: number
/**
* E9 "coluna da vergonha": priority municipalities in scope with nobody
* answering for them — the one gap the coordination can close today, so it
* gets named as a number and a filtered link instead of hiding inside the
* assessoria ratio.
*/
priorityWithoutAdvisorCount: number
/** E8 "conta da cadeira" — meta × comprometido do escopo filtrado, por cenário. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
/** Values still reachable under the OTHER active filters (column-filter options). */
type MunicipalityListFilterFacets = {
/** Catalog order. */
@ -91,7 +71,6 @@ export type MunicipalityListPageBundle = {
totalDocs: number
totalPages: number
scopeTotal: number
overview: MunicipalityListOverviewData | null
filterFacets: MunicipalityListFilterFacets
}
@ -106,10 +85,9 @@ type MunicipalityFacetRow = Pick<Municipality, 'slug' | 'region' | 'advisors'>
/**
* E10's class filter is the only one that isn't a Payload constraint (the
* class is derived from the committed TSE artifact, not stored), so every
* place that would otherwise trust `where` — the page query, the overview
* scope and the facets — has to apply this predicate itself. Returns `null`
* when no class is selected, which is also the signal to keep the cheap
* database-paginated path.
* place that would otherwise trust `where` — the page query and the facets —
* has to apply this predicate itself. Returns `null` when no class is selected,
* which is also the signal to keep the cheap database-paginated path.
*/
const territorialClassFilterPredicate = (
state: MunicipalityListState,
@ -320,7 +298,6 @@ export const loadMunicipalityListPageBundle = async (
totalDocs: 0,
totalPages: 0,
scopeTotal: 0,
overview: null,
filterFacets: emptyMunicipalityListFilterFacets,
}
}
@ -389,24 +366,17 @@ export const loadMunicipalityListPageBundle = async (
isStaff ? loadStatewideSuggestedGoals(payload, user) : null,
])
let overview: MunicipalityListOverviewData | null = null
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
let goalCoverageByMunicipalityID = new Map<
number,
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
>()
// Computed even for an empty filtered scope: the overview stays on screen
// (zeroed) next to the empty state instead of disappearing with the rows.
if (staffScope) {
pledgeAggregates = staffScope.pledgeAggregates
// Never mutate the scope: `loadMunicipalityScope` is request-cached and
// shared with the facets. Filtering here is what keeps the overview
// (E8 coverage, E9 shame column) counting the filtered scope.
const scopedMunicipalities = classMatches
? staffScope.municipalities.filter((municipality) => classMatches(municipality.slug))
: staffScope.municipalities
const rollup = rollupMunicipalityStaffVotes(scopedMunicipalities, pledgeAggregates)
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
@ -414,20 +384,6 @@ export const loadMunicipalityListPageBundle = async (
pledgeAggregates,
)
goalCoverageByMunicipalityID = goalCoverageBundle.coverageByMunicipalityID
overview = {
municipalityCount: scopedMunicipalities.length,
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
pledgeCount: rollup.pledgeCount,
missingEstimateCount: rollup.missingEstimateCount,
withAdvisorCount: scopedMunicipalities.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
priorityWithoutAdvisorCount: scopedMunicipalities.filter(
(municipality) =>
municipality.priority === 'alta' && (municipality.advisors ?? []).length === 0,
).length,
goalCoverageByScenario: goalCoverageBundle.aggregateByScenario,
}
}
let pageDocs: Municipality[]
@ -468,7 +424,6 @@ export const loadMunicipalityListPageBundle = async (
totalDocs,
totalPages,
scopeTotal: scopeCount.totalDocs,
overview,
filterFacets,
}
}

View file

@ -153,7 +153,10 @@ test.describe('Municípios — jornadas por papel', () => {
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Editar tendência política' }).click()
const trendButton = page.getByRole('button', {
name: new RegExp(`^Editar tendência política em ${municipality.name}`),
})
await trendButton.click()
const trendPopover = page.locator('[data-slot="popover-content"]')
await expect(trendPopover).toBeVisible()
await expect(trendPopover.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
@ -165,13 +168,11 @@ test.describe('Municípios — jornadas por papel', () => {
trendPopover.getByLabel('Justificativa').blur(),
])
await expect(page.getByRole('button', { name: 'Editar tendência política' })).toContainText(
'Favorável',
)
await expect(trendButton).toContainText('Favorável')
await page.keyboard.press('Escape')
await page.reload()
await page.getByRole('button', { name: 'Editar tendência política' }).click()
await trendButton.click()
const reopened = page.locator('[data-slot="popover-content"]')
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel')
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
@ -428,6 +429,8 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
const fab = page.getByRole('button', { name: 'Ações rápidas' })
await expect(fab).toBeVisible()
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
@ -478,7 +481,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(page.getByRole('button', { name: 'Ações rápidas' })).toBeVisible()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Ações rápidas' }).click()
const overlay = page.locator('#CampaignQuickActionsOverlay')

View file

@ -562,6 +562,10 @@ export const test = base.extend<CampaignE2ETestFixtures>({
})
export const campaignPageChrome = (page: Page, title: string) =>
page.locator('[data-slot="campaign-page-chrome"]').filter({ visible: true }).getByText(title)
page
.locator('[data-slot="campaign-page-chrome"]')
.filter({ visible: true })
.getByText(title)
.first()
export { expect }

View file

@ -9,8 +9,6 @@ import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -27,62 +25,25 @@ describe('loadMunicipalityListPageBundle', () => {
payload = await getPayload({ config: await config })
})
it('keeps a zeroed overview when the filtered municipality set is empty', async () => {
it('returns an empty list when the filtered municipality set is empty', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'zzznomatch' })
// The overview stays on screen next to the empty state instead of unmounting.
expect(bundle.municipalities).toHaveLength(0)
expect(bundle.overview).not.toBeNull()
expect(bundle.overview!.municipalityCount).toBe(0)
expect(bundle.overview!.staffVoteTotalByScenario.central).toBe(0)
expect(bundle.overview!.goalCoverageByScenario.central.goal).toBe(0)
expect(bundle.totalDocs).toBe(0)
})
it('rolls up staffVoteTotal from expectedVotes when no pledge overrides apply', async () => {
it('includes 2022 vote position on list rows', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
fixtures.touchMunicipality(municipality.id)
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { expectedVotes: { pessimistic: null, central: 1_500, optimistic: null } },
depth: 0,
overrideAccess: true,
})
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
})
await fixtures.createVotePledge({
leadership: leadership.id,
municipality: municipality.id,
declaredVotes: 80,
estimatedVotes: { pessimistic: null, central: 120, optimistic: null },
})
const refreshedMunicipality = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
overrideAccess: true,
})
const aggregates = await aggregatePledgesByMunicipality(payload, [municipality.id])
const rollup = rollupMunicipalityStaffVotes([refreshedMunicipality], aggregates)
expect(rollup.staffVoteTotal).toBe(1_500)
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
q: municipality.name,
})
expect(bundle.overview).not.toBeNull()
expect(bundle.overview!.staffVoteTotalByScenario.central).toBeGreaterThanOrEqual(1_500)
const row = bundle.municipalities.find((item) => item.slug === municipality.slug)
expect(row?.votePosition2022).not.toBeNull()
expect(row!.votePosition2022!.totalUnits).toBe(435)
@ -282,9 +243,9 @@ describe('loadMunicipalityListPageBundle', () => {
/**
* E10: the class is derived from the committed TSE artifact, so the filter
* cannot be a Payload constraint. The bundle must still agree with itself —
* rows, `totalDocs` and the overview all counting the filtered scope.
* rows and `totalDocs` counting the filtered scope.
*/
it('filters by the derived territorial class and keeps the overview honest', async () => {
it('filters by the derived territorial class and keeps totals honest', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
@ -326,7 +287,6 @@ describe('loadMunicipalityListPageBundle', () => {
})
expect(filtered.municipalities.map((row) => row.slug)).toEqual([first.slug])
expect(filtered.totalDocs).toBe(1)
expect(filtered.overview?.municipalityCount).toBe(1)
expect(filtered.municipalities[0]!.territorialClass).toBe(firstClass)
expect(filtered.municipalities[0]!.territorialClassFactors.length).toBeGreaterThan(0)
@ -411,7 +371,6 @@ describe('loadMunicipalityListPageBundle', () => {
const included = await loadMunicipalityListPageBundle(payload, advisor, {
q: administered.name,
})
expect(included.overview).not.toBeNull()
expect(included.municipalities.some((row) => row.slug === administered.slug)).toBe(true)
const outsideSlug = municipalityCatalog.find((entry) => entry.slug !== administered.slug)!.slug
@ -419,7 +378,6 @@ describe('loadMunicipalityListPageBundle', () => {
const excluded = await loadMunicipalityListPageBundle(payload, advisor, {
slug: [outsideSlug],
})
expect(excluded.overview?.municipalityCount).toBe(0)
expect(excluded.municipalities).toHaveLength(0)
})
@ -498,7 +456,6 @@ describe('loadMunicipalityListPageBundle', () => {
const bundle = await loadMunicipalityListPageBundle(payload, leader, { q: municipality.name })
expect(bundle.overview).toBeNull()
expect(bundle.municipalities).toHaveLength(0)
expect(bundle.scopeTotal).toBe(0)
})