Merge branch 'cursor/13b5f7a8' (B56 campaign summary on Início)
This commit is contained in:
commit
daf3229a08
10 changed files with 205 additions and 17 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
34
src/components/campaign/dashboard/CampaignHomeSummary.tsx
Normal file
34
src/components/campaign/dashboard/CampaignHomeSummary.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
52
tests/unit/campaignHomeSummary.unit.spec.tsx
Normal file
52
tests/unit/campaignHomeSummary.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue