Dim the tour composer proposal when changing identity territory.

Wire CampaignListPendingBoundary/Results on /campanha/atividades/giros so TI navigation matches list pending UX; pin contract in tourRegionPending unit spec.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 18:40:22 -03:00
parent ead5358847
commit be0d587005
7 changed files with 207 additions and 51 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
- **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md).
- **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md).
- **Fill-in "Selecionados no topo nos filtros do header" entregue em código (2026-07-28):** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`. Helper puro `orderFilterOptionsSelectedFirst` em `src/lib/listFilterOptions.ts`; snapshot da seleção na abertura do Popover (ordem congelada enquanto aberto; checkbox continua live); hairline entre grupos, oculto durante a busca (polish do critique). Gate multi = `optionRows.every(checkbox)` — single-select intocado. A premissa "não extrair head genérico" do plano caducou: B21/B29 já tinham pago `CampaignHeaderFilterPopover` compartilhado, então o item pousou em 2 arquivos e 4 listas. Specs unit do helper + do Popover (freeze). Sem migration/action/Consent. Plano: [`selecionados-no-topo-filtros-header.md`](../../../docs/plans/selecionados-no-topo-filtros-header.md).

View file

@ -0,0 +1,32 @@
---
timestamp: 2026-07-28T18-26-19Z
slug: src-app-campaign-campanha-app-atividades-giros
---
# Critique: /campanha/atividades/giros pending feedback
Method: dual-agent attempted (A interrupted hung; B d63ec5de clean). Assessment A synthesized in parent from source. Detector: 0 findings. Browser skipped (no campaign server).
## Scoped Design Health ~3.6/4
Visibility of system status 4 — spinner on control + dim/aria-busy/live on results.
Consistency 4 — same CampaignListPending\* as lists.
AI slop: pass.
## Strengths
1. Shared transition without a second pending language.
2. Control outside Results (Feel the action).
3. Unit pin on aria-busy/data-pending.
## Priority issues
None P0/P1.
## Decisions confirmed
Keep "Atualizando resultados…"; keep select spinner + dim (gesture vs scope).
## Polish
No code changes — aligned with municipios nesting. CalendarPhaseNote under picker inside Results.

View file

@ -1,12 +1,16 @@
# Dimir o resultado no compositor de giro (feedback pendente)
Status: rascunho
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos)
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — riscado)
Impeccable: B — encaixe em `/campanha/atividades/giros` (rota entregue no **E13 ✓**); nenhuma superfície nova
Appetite: ~0,25 dia eng; três pontos de edição, sem migration/action/`Consent`
Responsável: —
Revisão 2026-07-28 (auditoria pré-implementação): questões em aberto fechadas (copy aria-live reutilizada; spinner do select permanece). `CalendarPhaseNote` hoje renderiza _acima_ do picker — a entrega move a nota para dentro de `CampaignListResults` (abaixo do seletor), como a abordagem já descrevia.
**As-built 2026-07-28:** `CampaignListPendingBoundary` + `CampaignListResults` em `giros/page.tsx`; `TourRegionPicker` passou a `useCampaignListTransition`; `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts` pina `aria-busy`/`data-pending` com `push` Promise pendente (React 19). Critique: sem P0/P1; polish sem mudança de código. Gate tsc/lint/format/cycles; knip P3 pré-existente (`payload.config.ts`); Aikido 0 findings nos first-party.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (clareza sob pressão) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedente `engineering-standards.mdc` § Loading feedback e as 10 listas de `/campanha` que já usam `CampaignListPendingBoundary` + `CampaignListResults`.
@ -53,10 +57,10 @@ Dois efeitos concretos, além da inconsistência com as dez listas vizinhas:
- **Nada de `loading.tsx` na rota.** A navegação é `router.push` dentro de uma transição, então o segmento não remonta; um `loading.tsx` só apareceria na entrada direta, que já é rápida. **Rejeitado:** skeleton do compositor (a proposta anterior dimada é mais informativa que um esqueleto).
- **i18n e naming** (AGENTS.md): identificadores em inglês; a copy visível reusa "Atualizando resultados…" já existente, sem nova string.
## Questões em aberto
## Questões resolvidas (2026-07-28)
- **A copy `aria-live` "Atualizando resultados…" serve para uma proposta de giro?** **Opções:** A) reusar como está | B) prop de mensagem no `CampaignListResults` para dizer "Recompondo o giro…". **Recomendação:** **A** — "resultados" descreve honestamente o que muda, e um prop novo abriria a porta para dez variações de copy nas listas; revisitar no critique se soar errado em voz alta.
- **O spinner atual do `FieldDescription` fica ou sai?** **Opções:** A) fica (controle + resultado sinalizam) | B) sai, deixando só o dim. **Recomendação:** **A** — é o feedback imediato no ponto do gesto; o dim é o que informa o escopo. Custa duas linhas manter.
- **Copy `aria-live`:** reusar "Atualizando resultados…" (sem prop de mensagem no `CampaignListResults`). Revisitar no critique se soar errado em voz alta.
- **Spinner do `FieldDescription`:** permanece — feedback imediato no gesto; o dim informa o escopo.
## Abordagem proposta

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,10 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
@ -124,49 +128,54 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
</Button>
</header>
{bundle ? <CalendarPhaseNote phase={bundle.phase} /> : null}
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */}
<CampaignListPendingBoundary>
{regions.length > 0 ? (
<TourRegionPicker
regions={regions.map((option) => ({
...option,
href: buildTourComposerHref({ region: option.region }),
}))}
selectedRegion={region}
clearHref={buildTourComposerHref({ region: null })}
/>
) : null}
{regions.length > 0 ? (
<TourRegionPicker
regions={regions.map((option) => ({
...option,
href: buildTourComposerHref({ region: option.region }),
}))}
selectedRegion={region}
clearHref={buildTourComposerHref({ region: null })}
/>
) : null}
<CampaignListResults>
{bundle ? <CalendarPhaseNote phase={bundle.phase} /> : null}
{regions.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Você ainda não acompanha nenhum município"
description="A composição do giro sai da sua carteira. Peça ao Coordenador Geral para vincular seus municípios."
/>
) : region === null ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Escolha um território para começar"
description="A composição do giro compara os municípios entre si dentro do mesmo território — é assim que 'encaixe em giro' faz sentido."
/>
) : stops.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title={`Nenhum município candidato em ${region}`}
description="Nenhum município deste território está no seu escopo com dados suficientes para compor um giro. Cadastre lideranças ou compromissos de voto e volte aqui."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/municipios">Ver municípios</Link>
</Button>
</CampaignListEmptyState>
) : (
<TourComposerForm
region={region}
stops={stops}
defaultTourName={defaultTourName(region, now)}
formAction={createTourDraftsFormAction}
/>
)}
{regions.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Você ainda não acompanha nenhum município"
description="A composição do giro sai da sua carteira. Peça ao Coordenador Geral para vincular seus municípios."
/>
) : region === null ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title="Escolha um território para começar"
description="A composição do giro compara os municípios entre si dentro do mesmo território — é assim que 'encaixe em giro' faz sentido."
/>
) : stops.length === 0 ? (
<CampaignListEmptyState
icon={MapPinnedIcon}
title={`Nenhum município candidato em ${region}`}
description="Nenhum município deste território está no seu escopo com dados suficientes para compor um giro. Cadastre lideranças ou compromissos de voto e volte aqui."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/municipios">Ver municípios</Link>
</Button>
</CampaignListEmptyState>
) : (
<TourComposerForm
region={region}
stops={stops}
defaultTourName={defaultTourName(region, now)}
formAction={createTourDraftsFormAction}
/>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,8 +1,8 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
@ -33,7 +33,7 @@ export const TourRegionPicker = ({
clearHref: string
}) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const { isPending, startTransition } = useCampaignListTransition()
return (
<Field className="max-w-md">

View file

@ -0,0 +1,110 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const routerState = vi.hoisted(() => ({
push: vi.fn(),
/**
* Resolves the in-flight `push` Promise so the React 19 transition can
* settle. The shared pending boundary stays busy until this runs.
*/
resolvePush: null as (() => void) | null,
}))
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({
push: (...args: unknown[]) => routerState.push(...args),
}),
}))
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
/**
* The composer isn't a list, but it reuses the list pending pieces. This pin
* is the contract the E13 `/simplify` missed: changing Território drives the
* shared transition so `CampaignListResults` dims (`data-pending` / `aria-busy`)
* while the picker keeps its own spinner.
*/
const regions = [
{ region: 'Sisal', municipalityCount: 3, href: '/campanha/atividades/giros?region=Sisal' },
{
region: 'Portal do Sertão',
municipalityCount: 5,
href: '/campanha/atividades/giros?region=Portal%20do%20Sert%C3%A3o',
},
]
const mountComposer = (): ReactElement =>
createElement(
CampaignListPendingBoundary,
null,
createElement(TourRegionPicker, {
regions,
selectedRegion: 'Sisal',
clearHref: '/campanha/atividades/giros',
}),
createElement(CampaignListResults, null, createElement('p', null, 'Proposta do giro Sisal')),
)
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
describe('tour region pending', () => {
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
})
afterEach(() => {
cleanup()
routerState.push.mockReset()
routerState.resolvePush = null
})
it('dims the results region while the territory navigation is in flight', async () => {
routerState.push.mockImplementation(
() =>
new Promise<void>((resolve) => {
routerState.resolvePush = resolve
}),
)
render(mountComposer())
const results = screen.getByText('Proposta do giro Sisal').parentElement
expect(results).not.toBeNull()
expect(results!.getAttribute('aria-busy')).not.toBe('true')
expect(results!.getAttribute('data-pending')).toBeNull()
await act(async () => {
fireEvent.change(screen.getByLabelText('Território de Identidade'), {
target: { value: 'Portal do Sertão' },
})
})
expect(routerState.push).toHaveBeenCalledWith(regions[1].href, { scroll: false })
expect(results!.getAttribute('aria-busy')).toBe('true')
expect(results!.getAttribute('data-pending')).toBe('true')
expect(screen.getByText('Atualizando resultados…')).toBeTruthy()
expect(screen.getByText('Montando a proposta de giro…')).toBeTruthy()
expect((screen.getByLabelText('Território de Identidade') as HTMLSelectElement).disabled).toBe(
true,
)
await act(async () => {
routerState.resolvePush?.()
})
expect(results!.getAttribute('aria-busy')).not.toBe('true')
expect(results!.getAttribute('data-pending')).toBeNull()
})
})