B129 — Remove municipality list overview panel

Remove MunicipalityListOverview from /campanha/municipios and drop the
overview field/computation from loadMunicipalityListPageBundle. Filters,
scenario selector, and per-row goal coverage for sorting remain unchanged.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 19:03:07 +00:00
parent f176412e2b
commit b4f2e2fa0a
No known key found for this signature in database
7 changed files with 91 additions and 251 deletions

View file

@ -0,0 +1,53 @@
# 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.
## 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

@ -1,3 +1,4 @@
<<<<<<< HEAD
# Remover overview da lista de municípios
Status: registrado
@ -74,3 +75,27 @@ Na lista de municípios (`/campanha/municipios`), o painel de visão geral acima
- `src/app/(campaign)/campanha/(app)/municipios/page.tsx`
- `docs/plans/overview-lista-nucleos.md` · `docs/plans/cenario-junto-filtros-municipios.md`
- E9 / “coluna da vergonha” — contexto em `docs/plans/inteligencia-campanha.md` (não reabrir fila de alocação aqui)
=======
# B129 — Remover overview da lista de municípios
Status: aprovado
Atualizado em: 2026-08-02
Issue: #266
## Outcome
Tirar o painel de visão geral (“Média nos municípios filtrados” e demais agregados) de `/campanha/municipios` em todos os viewports, para a lista voltar a ser o conteúdo útil logo após os filtros.
## Aceite (produto)
- Overview da lista de municípios ausente em desktop e mobile.
- Filtros e seletor de cenário na barra de filtros permanecem.
- Atalho E9 “prioritárias sem responsável” some com o bloco (filtros Prioridade + Assessoria cobrem o recorte).
- Fora: overview de apoiadores, Início/dashboard, redesign da lista.
## Direção no codebase (hipótese)
- Remover `MunicipalityListOverview` da página e deletar o componente.
- Retirar `overview` de `loadMunicipalityListPageBundle`; manter `staffScope` só para agregados de linha (pledges, cobertura de meta por município).
- Atualizar int tests que assertam `bundle.overview`.
>>>>>>> 0c052371 (B129 — Remove municipality list overview panel)

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'
@ -60,7 +59,11 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
municipalities: listMunicipalities,
totalDocs,
totalPages,
<<<<<<< HEAD
overview,
=======
scopeTotal,
>>>>>>> c432f439 (B129 — Remove municipality list overview panel)
filterFacets,
} = pageBundle
const resolvedUrl = resolveMunicipalityListUrl(rawSearchParams, totalPages)
@ -109,20 +112,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,8 +85,8 @@ 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`
* 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,
* when no class is selected, which is also the signal to keep the cheap
* database-paginated path.
*/
@ -320,7 +299,6 @@ export const loadMunicipalityListPageBundle = async (
totalDocs: 0,
totalPages: 0,
scopeTotal: 0,
overview: null,
filterFacets: emptyMunicipalityListFilterFacets,
}
}
@ -389,24 +367,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 +385,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 +425,6 @@ export const loadMunicipalityListPageBundle = async (
totalDocs,
totalPages,
scopeTotal: scopeCount.totalDocs,
overview,
filterFacets,
}
}

View file

@ -27,17 +27,13 @@ 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 () => {
@ -81,8 +77,6 @@ describe('loadMunicipalityListPageBundle', () => {
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,7 +276,7 @@ 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 () => {
const fixtures = campaignFixtures()
@ -326,7 +320,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 +404,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 +411,6 @@ describe('loadMunicipalityListPageBundle', () => {
const excluded = await loadMunicipalityListPageBundle(payload, advisor, {
slug: [outsideSlug],
})
expect(excluded.overview?.municipalityCount).toBe(0)
expect(excluded.municipalities).toHaveLength(0)
})
@ -498,7 +489,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)
})