From b4f2e2fa0a26ce2f82ad6dd25137e5e71505ba59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 19:03:07 +0000 Subject: [PATCH] =?UTF-8?q?B129=20=E2=80=94=20Remove=20municipality=20list?= =?UTF-8?q?=20overview=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../remover-overview-lista-municipios-impl.md | 53 ++++++ .../remover-overview-lista-municipios.md | 25 +++ .../campanha/(app)/municipios/page.tsx | 19 +- .../municipality/MunicipalityList.tsx | 2 +- .../municipality/MunicipalityListOverview.tsx | 177 ------------------ .../municipality/municipalityPageData.ts | 50 +---- tests/int/municipalityPageData.int.spec.ts | 16 +- 7 files changed, 91 insertions(+), 251 deletions(-) create mode 100644 docs/plans/remover-overview-lista-municipios-impl.md delete mode 100644 src/components/campaign/municipality/MunicipalityListOverview.tsx diff --git a/docs/plans/remover-overview-lista-municipios-impl.md b/docs/plans/remover-overview-lista-municipios-impl.md new file mode 100644 index 00000000..3289ab98 --- /dev/null +++ b/docs/plans/remover-overview-lista-municipios-impl.md @@ -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`) diff --git a/docs/plans/remover-overview-lista-municipios.md b/docs/plans/remover-overview-lista-municipios.md index 7c16feea..c654b71f 100644 --- a/docs/plans/remover-overview-lista-municipios.md +++ b/docs/plans/remover-overview-lista-municipios.md @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index fd8cbcbd..8d2e8453 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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 ? ( - - ) : null} ( diff --git a/src/components/campaign/municipality/MunicipalityListOverview.tsx b/src/components/campaign/municipality/MunicipalityListOverview.tsx deleted file mode 100644 index 5a8f1c1a..00000000 --- a/src/components/campaign/municipality/MunicipalityListOverview.tsx +++ /dev/null @@ -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

Nenhuma prioritária sem responsável

- } - - const label = `${formatElectionNumber(priorityWithoutAdvisorCount)} ${ - priorityWithoutAdvisorCount === 1 ? 'prioritária' : 'prioritárias' - } sem responsável` - - if (!shameHref) { - return

{label}

- } - - return ( - - - ) -} - -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 | 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 = ( -
- {hasScenarioTotals ? ( - - ) : null} - {endpointsLabel ? ( -

{endpointsLabel}

- ) : null} - {view.missingEstimateCount > 0 ? ( -

- {formatElectionNumber(view.missingEstimateCount)}{' '} - {view.missingEstimateCount === 1 ? 'declaração' : 'declarações'} sem estimativa -

- ) : null} -
- ) - - return ( -
- 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: ( - - ), - 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), - }, - ]} - /> -
- ) -} diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts index dd5a7be1..5dea27d4 100644 --- a/src/utilities/municipality/municipalityPageData.ts +++ b/src/utilities/municipality/municipalityPageData.ts @@ -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 - 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 -} - /** 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 /** * 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() let goalCoverageByMunicipalityID = new Map< number, Record >() - // 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, } } diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index d0edfa7b..755ba7d1 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -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) })