Merge branch 'cursor/13b5f7a8' (B56 campaign summary on Início)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 17:56:23 -03:00
commit daf3229a08
10 changed files with 205 additions and 17 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B56 entregue em código (2026-07-29):** resumo da campanha no Início staff — `loadCampaignHomeSummary` + `CampaignHomeSummary` (total central `staffVoteTotal` + cobertura E8 com `Progress`); `summarySlot` em `CampaignHomeLayout`/`CampaignHomeStaffChrome`, oculto no modo focado B47; sem saudação; leader fora. Loader em `campaignDashboardData.ts` (não módulo top-level novo). Unit `campaignHomeSummary.unit.spec.tsx`. Sem migration. Plano: [`resumo-campanha-inicio.md`](../../../docs/plans/resumo-campanha-inicio.md). Desbloqueia slot visual de **B57** (delta 7d, futuro).
- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta com pan preservado; escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). Próximo chassis: **B48** resultados busca.
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças (mesmo payload); **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).

View file

@ -1,6 +1,6 @@
# Resumo da campanha no Início (total + cobertura)
Status: rascunho
Status: entregue
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B56)
Impeccable: C — bloco KPI novo no Início (não é strip do Quadro)
@ -75,7 +75,7 @@ flowchart TB
Componentes:
- **`loadCampaignHomeSummary`** (`utilities/` — p.ex. `campaignHomeSummaryData.ts`, `server-only`): `getCampaignUser` / actor já na page; `loadMunicipalityScope` + `aggregateAllPledgesByMunicipality` (ou o caminho que o dashboard já usa) → `rollupMunicipalityStaffVotes` + `loadMunicipalityGoalCoverageBundle`; devolver `{ staffVoteTotalCentral, goalCoverage }` (só `central`). `overrideAccess: false` com `user`.
- **`loadCampaignHomeSummary`** em [`src/utilities/campaignDashboardData.ts`](../../src/utilities/campaignDashboardData.ts) (`server-only`, colocado no módulo do Quadro em vez de `campaignHomeSummaryData.ts` top-level): `loadMunicipalityScope` + `rollupMunicipalityStaffVotes` + `loadMunicipalityGoalCoverageBundle`; devolve `{ staffVoteTotalCentral, goalCoverage }` (só `central`). `overrideAccess: false` com `user`.
- **`CampaignHomeSummary.tsx`** (`components/campaign/dashboard/`): RSC ou server-friendly; `formatElectionNumber`; `Progress`; sem import de catálogo/Bahia no client.
- **Wire-up** em `page.tsx`: acima de ações/busca na ordem de leitura; no mobile o **B46** continua ancorando a strip embaixo — o resumo fica no fluxo superior do scroll.
- **Migration:** Sem migration, sem collection, sem server action.
@ -113,3 +113,13 @@ Componentes:
- `src/app/(campaign)/campanha/(app)/page.tsx` — mount atual
- `docs/plans/conta-da-cadeira.md` (E8) — semântica de cobertura
- `PRODUCT.md` / `DESIGN.md` — anti hero-metric; tipografia
**Revisão 2026-07-29 (entrega):** loader slim colocado em `campaignDashboardData.ts`; `CampaignHomeSummary` + `summarySlot` no layout staff com ocultação no modo focado B47; specs `campaignHomeSummary.unit.spec.tsx` e extensão de `campaignHomeSearch.unit.spec.tsx`.
## Explicitamente fora (pós-`/simplify`)
- `CampaignMetricStrip` no Início — anti-goal do plano; Quadro já cobre multi-KPI.
- Componente compartilhado Progress+label — só dois call sites (resumo + métricas); extrair quando ≥3.
- Renomear rótulo para “Cobertura da meta” sem sign-off de produto (copy atual alinhada ao plano).
- Helper E8 só-agregado / int test do loader — cobertura unit do componente + rollup existente bastam por ora.
- Módulo top-level `campaignHomeSummaryData.ts` — loader permanece em `campaignDashboardData.ts` (pin + um call site).

File diff suppressed because one or more lines are too long

View file

@ -1,17 +1,23 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import type { ReactNode } from 'react'
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildMunicipalityListHref } from '@/utilities/municipality/municipalityListUrl'
export const dynamic = 'force-dynamic'
export default async function CampaignHomePage() {
const user = await requireCampaignPageActor()
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
const staff = isStaffCampaignRole(user.role)
@ -26,6 +32,12 @@ export default async function CampaignHomePage() {
/>
)
let summarySlot: ReactNode | undefined
if (staff) {
const summaryView = await loadCampaignHomeSummary(payload, user)
summarySlot = <CampaignHomeSummary view={summaryView} />
}
return (
<CampaignPageShell aria-label="Início" className="min-h-full">
{staff ? (
@ -36,6 +48,7 @@ export default async function CampaignHomePage() {
<HomeSearchMunicipalityGroup />
</HomeSearchResultsShell>
}
summarySlot={summarySlot}
/>
) : (
<CampaignHomeLayout actions={actions} />

View file

@ -5,14 +5,21 @@ import { cn } from '@/lib/utils'
export const CampaignHomeLayout = ({
actions,
searchSlot,
summarySlot,
focused = false,
}: {
actions: ReactNode
searchSlot?: ReactNode
summarySlot?: ReactNode
/** When true (trimmed debounced query length ≥ min), hide thumb-zone spacer and action strip. */
focused?: boolean
}) => (
<div className="flex min-h-full w-full flex-col md:min-h-0">
{summarySlot ? (
<div className={cn('order-0 min-w-0', focused && 'hidden')} data-slot="home-summary">
{summarySlot}
</div>
) : null}
{focused ? null : (
<div aria-hidden className="min-h-0 flex-1 md:hidden" data-slot="home-thumb-spacer" />
)}

View file

@ -27,10 +27,12 @@ const CampaignHomeStaffSearchSlot = ({ searchResults }: { searchResults: ReactNo
export const CampaignHomeStaffChrome = ({
actions,
searchResults,
summarySlot,
}: {
actions: ReactNode
/** Result groups plug in here from B48+. */
searchResults?: ReactNode
summarySlot?: ReactNode
}) => {
const searchState = useHomeSearchQuery()
const focused = searchState.query.isActive
@ -51,6 +53,7 @@ export const CampaignHomeStaffChrome = ({
<CampaignHomeSearch />
)
}
summarySlot={summarySlot}
/>
</div>
</HomeSearchProvider>

View file

@ -0,0 +1,34 @@
import { Progress } from '@/components/ui/Progress'
import { formatElectionNumber } from '@/lib/electionFormat'
import type { CampaignHomeSummaryView } from '@/utilities/campaignDashboardData'
import {
formatGoalCoverageRatioLabel,
goalCoverageProgressPercent,
} from '@/utilities/municipality/goalCoverage'
export const CampaignHomeSummary = ({ view }: { view: CampaignHomeSummaryView }) => (
<section aria-label="Resumo da campanha" className="flex flex-col gap-4 pb-4 md:pb-6">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">Votos estimados</p>
<p className="text-3xl font-semibold tabular-nums tracking-tight">
{formatElectionNumber(view.staffVoteTotalCentral)}
</p>
</div>
<div className="flex max-w-md flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Cobertura por lideranças</span>
<span className="font-medium tabular-nums">
{formatGoalCoverageRatioLabel(view.goalCoverage)}
</span>
</div>
{view.goalCoverage.coverageRatio != null ? (
<Progress
aria-label={`Cobertura por lideranças: ${formatGoalCoverageRatioLabel(view.goalCoverage)}`}
className="h-2"
value={goalCoverageProgressPercent(view.goalCoverage)}
/>
) : null}
</div>
</section>
)

View file

@ -7,8 +7,8 @@ import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, VotePledge } from '@/payload-types'
import {
type DashboardPriorityMunicipality,
pickDashboardPriorityMunicipalities,
type DashboardPriorityMunicipality,
} from '@/utilities/dashboardPriorityMunicipalities'
import {
loadCampaignUserNamesByIds,
@ -19,8 +19,12 @@ import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCove
import {
loadMunicipalityGoalCoverageBundle,
loadStatewideSuggestedGoals,
type MunicipalityGoalCoverageBundle,
} from '@/utilities/municipality/municipalityGoalAccount'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
import {
rollupMunicipalityStaffVotes,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeViews'
export type StaffDashboardView = {
kind: 'staff'
@ -58,6 +62,29 @@ export type StaffDashboardView = {
}>
}
type ScopedMunicipalityDoc = Awaited<
ReturnType<typeof loadMunicipalityScope>
>['municipalities'][number]
const loadStaffRollupAndGoalCoverageBundle = async (
payload: Payload,
user: CampaignUser,
municipalities: ReadonlyArray<ScopedMunicipalityDoc>,
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<{
rollup: ReturnType<typeof rollupMunicipalityStaffVotes>
goalCoverageBundle: MunicipalityGoalCoverageBundle
}> => {
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
municipalities,
pledgeAggregates,
)
const rollup = rollupMunicipalityStaffVotes(municipalities, pledgeAggregates)
return { rollup, goalCoverageBundle }
}
/**
* Staff dashboard data. Leaders never reach the dashboard — they are routed to
* the supporter contact tool before this is called (see `/campanha` page).
@ -118,15 +145,13 @@ export const getCampaignDashboardData = async (
loadStatewideSuggestedGoals(payload, user),
])
const rollup = rollupMunicipalityStaffVotes(municipalities.docs, pledgeAggregates)
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
const { rollup, goalCoverageBundle } = await loadStaffRollupAndGoalCoverageBundle(
payload,
user,
municipalities.docs,
pledgeAggregates,
)
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
const leadershipIDs = [
...new Set(
@ -195,3 +220,27 @@ export const getCampaignDashboardData = async (
}),
}
}
/** Staff Início: central staff vote total + E8 aggregate coverage (Quadro KPI contract). */
export type CampaignHomeSummaryView = Pick<StaffDashboardView, 'goalCoverage'> & {
staffVoteTotalCentral: StaffDashboardView['staffVoteTotalByScenario']['central']
}
/** Slim loader for `/campanha` — scope + rollup + coverage only (no map, suggestions, or queues). */
export const loadCampaignHomeSummary = async (
payload: Payload,
user: CampaignUser,
): Promise<CampaignHomeSummaryView> => {
const scope = await loadMunicipalityScope(payload, user, {})
const { rollup, goalCoverageBundle } = await loadStaffRollupAndGoalCoverageBundle(
payload,
user,
scope.municipalities,
scope.pledgeAggregates,
)
return {
staffVoteTotalCentral: rollup.staffVoteTotalByScenario.central,
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
}
}

View file

@ -13,13 +13,32 @@ const providerValue = {
}
describe('CampaignHomeLayout focused mode', () => {
it('hides actions and thumb spacer when focused', () => {
it('hides actions, thumb spacer, and summary when focused', () => {
const { container } = render(
<CampaignHomeLayout focused actions={<p>Actions block</p>} searchSlot={<p>Search</p>} />,
<CampaignHomeLayout
focused
actions={<p>Actions block</p>}
searchSlot={<p>Search</p>}
summarySlot={<p>Summary block</p>}
/>,
)
expect(container.querySelector('[data-slot="home-thumb-spacer"]')).toBeNull()
expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden')
expect(container.querySelector('[data-slot="home-summary"]')?.className).toContain('hidden')
})
it('shows summary when not focused', () => {
render(
<CampaignHomeLayout
actions={<p>Actions block</p>}
summarySlot={<p data-testid="home-summary-stub">Summary stub</p>}
/>,
)
expect(
screen.getByTestId('home-summary-stub').closest('[data-slot="home-summary"]'),
).not.toBeNull()
})
})

View file

@ -0,0 +1,52 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
afterEach(() => {
cleanup()
})
describe('CampaignHomeSummary', () => {
it('renders central staff vote total and coverage bar', () => {
render(
<CampaignHomeSummary
view={{
staffVoteTotalCentral: 125_430,
goalCoverage: {
goal: 200_000,
committed: 80_000,
coverageRatio: 0.4,
deficit: 120_000,
},
}}
/>,
)
expect(screen.getByRole('region', { name: 'Resumo da campanha' })).toBeTruthy()
expect(screen.getByText('Votos estimados')).toBeTruthy()
expect(screen.getByText('125.430')).toBeTruthy()
expect(screen.getByText('Cobertura por lideranças')).toBeTruthy()
expect(screen.getByText('40%')).toBeTruthy()
expect(screen.getByRole('progressbar', { name: 'Cobertura por lideranças: 40%' })).toBeTruthy()
})
it('omits the progress bar when coverage is unknown', () => {
render(
<CampaignHomeSummary
view={{
staffVoteTotalCentral: 0,
goalCoverage: {
goal: 0,
committed: 0,
coverageRatio: null,
deficit: 0,
},
}}
/>,
)
expect(screen.getByText('—')).toBeTruthy()
expect(screen.queryByRole('progressbar')).toBeNull()
})
})