Merge branch 'cursor/a33b5d8c'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 07:53:41 -03:00
commit a0cd604f21
9 changed files with 107 additions and 89 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md).
- **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `<body>`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `<ul>` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
- **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).

View file

@ -1,8 +1,10 @@
# Escala e DRY pós-B17 (seletor de colunas)
Status: rascunho
Atualizado em: 2026-07-28
Status: entregue
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B17+**)
**Revisão (2026-07-29):** F1 implementado via condicionamento dos loaders em `dobradinhas/page.tsx` e `liderancas/page.tsx` à leitura prévia do cookie `campaign_columns`. F2 implementado movendo o `CampaignListEmptyState` para a prop `empty` nas tabelas de demandas, organizações e apoiadores.
Impeccable: A — N/A na F1 (só loader); a F2 move um bloco já desenhado (`CampaignListEmptyState`) para dentro de um shell existente, sem pixel novo
Appetite: ~0,5 dia eng, duas fases independentes. Se a F1 sozinha estourar meio dia, a F2 é cortável.
Responsável: —

File diff suppressed because one or more lines are too long

View file

@ -45,32 +45,37 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
if (redirectHref) redirect(redirectHref)
const columnVisibility = await readCampaignColumnVisibility('apoiadores')
const listBody = result.docs.length ? (
const listBody = (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
{result.docs.length > 0 && overview ? (
<SupporterListOverview view={overview} now={now} />
) : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
columnVisibility={columnVisibility}
empty={
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhum apoiador encontrado"
description="Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</CampaignListEmptyState>
}
/>
<CampaignListFooter
totalDocs={result.totalDocs}
singular="apoiador encontrado"
plural="apoiadores encontrados"
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
{result.docs.length > 0 ? (
<CampaignListFooter
totalDocs={result.totalDocs}
singular="apoiador encontrado"
plural="apoiadores encontrados"
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
) : null}
</>
) : (
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhum apoiador encontrado"
description="Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</CampaignListEmptyState>
)
return (

View file

@ -145,37 +145,36 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
/>
<CampaignListResults>
<CampaignTable
columns={demandColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={
<CampaignListEmptyState
icon={InboxIcon}
title="Nenhuma demanda por aqui"
description="Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação."
>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</CampaignListEmptyState>
}
/>
{rows.length ? (
<>
<CampaignTable
columns={demandColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
/>
<CampaignListFooter
totalDocs={totalDocs}
singular="demanda"
plural="demandas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildDemandListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={InboxIcon}
title="Nenhuma demanda por aqui"
description="Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação."
>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</CampaignListEmptyState>
)}
<CampaignListFooter
totalDocs={totalDocs}
singular="demanda"
plural="demandas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildDemandListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>

View file

@ -32,6 +32,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
@ -180,6 +181,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
getPayload({ config }),
])
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
const isLeadershipVisible =
resolveVisibleColumns([{ id: 'leaderships' }], columnVisibility.hiddenColumnIds).length > 0
const [
{ rows, totalDocs, totalPages, filterFacets },
leadershipOptions,
@ -187,7 +192,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
administeredIds,
] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
loadLeadershipOptions(payload, user),
isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
@ -195,7 +200,6 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
const { sort, dir } = resolveStateDeputyListSort(state)
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))

View file

@ -39,6 +39,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import {
resolvedPortfolioEntriesById,
@ -324,6 +325,10 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
getPayload({ config }),
])
const columnVisibility = await readCampaignColumnVisibility('liderancas')
const isStateDeputyVisible =
resolveVisibleColumns([{ id: 'stateDeputies' }], columnVisibility.hiddenColumnIds).length > 0
const [
{ rows, totalDocs, totalPages, filterFacets },
stateDeputyOptions,
@ -331,7 +336,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
administeredIds,
] = await Promise.all([
loadLeadershipListPageData(payload, user, canonicalUrl.state),
loadStateDeputyOptions(payload, user),
isStateDeputyVisible ? loadStateDeputyOptions(payload, user) : Promise.resolve([]),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
@ -360,7 +365,6 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
const { sort, dir } = resolveLeadershipListSort(state)
const sortSummary = formatLeadershipListSortSummary(sort, dir)
const columnVisibility = await readCampaignColumnVisibility('liderancas')
const columns = leadershipColumns({
state,
stateDeputyOptions: stateDeputyOptions.map((option) => ({

View file

@ -105,37 +105,36 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
/>
<CampaignListResults>
<CampaignTable
columns={organizationColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma organização cadastrada"
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</CampaignListEmptyState>
}
/>
{rows.length ? (
<>
<CampaignTable
columns={organizationColumns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
/>
<CampaignListFooter
totalDocs={totalDocs}
singular="organização"
plural="organizações"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildOrganizationListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma organização cadastrada"
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</CampaignListEmptyState>
)}
<CampaignListFooter
totalDocs={totalDocs}
singular="organização"
plural="organizações"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildOrganizationListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>

View file

@ -13,6 +13,7 @@ import type { SupporterListItemViewModel } from '@/utilities/supporter/supporter
export type SupporterListProps = {
supporters: SupporterListItemViewModel[]
columnVisibility: CampaignColumnVisibility
empty?: React.ReactNode
}
const voteIntentionBadgeVariant = (
@ -138,9 +139,10 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
},
]
export const SupporterList = ({ supporters, columnVisibility }: SupporterListProps) => (
export const SupporterList = ({ supporters, columnVisibility, empty }: SupporterListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{supporters.length === 0 ? empty : null}
{supporters.map((supporter) => (
<SupporterCard key={supporter.id} supporter={supporter} />
))}
@ -152,6 +154,7 @@ export const SupporterList = ({ supporters, columnVisibility }: SupporterListPro
columnVisibility={columnVisibility}
rows={supporters}
rowKey={(supporter) => supporter.id}
empty={empty}
/>
</>
)