Merge pull request #381 from fsolla/agent/B158-colunas-responsivas-municipios

B158 — Colunas responsivas por largura do conteúdo nos municípios
This commit is contained in:
Francisco Solla 2026-08-05 18:12:29 +00:00 committed by GitHub
commit 8a44ed5d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1318 additions and 463 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md).
**Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).

View file

@ -0,0 +1,190 @@
# Impl: B158 — Colunas adaptativas na tabela de Municípios
Status: aprovado
Atualizado em: 2026-08-05
Issue: #364
Intenção: docs/plans/colunas-responsivas-municipios.md
Appetite restante: ~1,5–2 dias eng; preservar o corte de um único outcome verificável
## Leitura da intenção
- **Outcome:** `/campanha/municipios` responde à largura real do painel de conteúdo: cards abaixo de 48rem; acima disso, uma tabela sem scroll horizontal que acrescenta contexto progressivamente, preserva preferência manual e mantém os quick edits acessíveis.
- **O que NÃO negociar:** container query em vez de viewport/JavaScript; ordem C0–C6; `leader` continua fora da rota; Dobradinha só para coordinator/candidate; território continua filtrável e `sort=region` continua válido; nenhum editor duplicado entre coluna adaptativa e `actions`; nenhuma migration, collection, Consent, server action ou mudança de URL pública.
- **O que reavaliar:** a intenção sugere um seam em `CampaignTable`, mas o código atual já aceita `className` em cada head e `cellClassName` em cada célula; a política responsiva pode e deve permanecer no dono local. A frase “uma instância por linha” será verificada dentro da superfície de tabela, porque cards e tabela já são árvores irmãs simultâneas no DOM, ocultadas por CSS; desmontar a superfície inativa exigiria estado/JavaScript e reabriria B42 fora do appetite.
## Abordagem recomendada
```mermaid
flowchart TD
Cookie[Cookie campaign_columns] --> Parse[Parser preserva ausente vs vazio explícito]
Parse --> Defaults[Defaults só quando municipios está ausente]
Defaults --> Manual[Visibilidade manual no servidor]
Manual --> Columns[Definições locais de colunas]
Width[Named container municipality-list] --> Capacity[Classes C0 a C6]
Capacity --> Columns
Columns --> Cards[Cards abaixo de 48rem]
Columns --> Table[Tabela a partir de 48rem]
Manual --> Actions[actions somente para trend ou lastSignal ocultos]
```
### Decisão 1 — onde vive a política responsiva
**Opções consideradas:**
- A. Classes de container query locais nas definições de `MunicipalityList`, aplicadas simetricamente a head/cell.
- B. Generalizar `CampaignTableColumn` com prioridade/breakpoint responsivo.
- C. Medir largura com `ResizeObserver` e recalcular colunas no cliente.
**Recomendação:** A — `CampaignTable` continua um módulo profundo para renderização e preferência manual; a matriz C0–C6 é política exclusiva de Municípios e todos os heads já aceitam `className`. Um record local de classes literais e tipadas evita divergência entre `<th>` e `<td>` sem criar API compartilhada prematura.
**Rejeitadas:** B vazaria uma política de uma lista para todas as tabelas e transformaria breakpoints de produto em configuração genérica; C adicionaria hidratação, estado e risco de flicker para um problema resolvido nativamente por CSS.
### Decisão 2 — ausência vs vazio explícito no cookie B17
**Opções consideradas:**
- A. Sentinel reservado `__none__` no formato atual (`municipios:__none__`).
- B. Cookie novo em JSON/versionado, migrando todos os consumidores.
- C. Manter o cookie atual e guardar um segundo cookie de “configurado”.
**Recomendação:** A — é retrocompatível, cabe no parser/serializer puro existente e não cria uma segunda fonte de verdade. O parser trata o sentinel somente como vazio explícito; ele nunca emerge como column ID. O serializer usa presença da chave para distinguir “ausente” de `[]`.
**Rejeitadas:** B amplia a migração e o risco de decoding entre `document.cookie` e `cookies()` sem benefício; C duplica estado e permite combinações contraditórias.
### Decisão 3 — montagem única de Tendência/Sinal
**Opções consideradas:**
- A. Definir uma função local por controle e chamá-la ou na coluna real ou em `actions`, conforme a ocultação manual resolvida no servidor.
- B. Renderizar coluna e `actions` sempre, escondendo uma cópia com CSS.
- C. Mover quick edits para um menu genérico de ações por linha.
**Recomendação:** A — apenas uma célula chama cada factory por linha. A coluna adaptativa permanece montada ao variar capacidade e troca compacto ↔ completo apenas por CSS; `actions` só é incluída na definição quando `trend` e/ou `lastSignal` está manualmente oculta.
**Rejeitadas:** B viola o contrato de uma instância e duplica estado/autosave; C remove edição “onde se vê”, muda o fluxo aprovado e cria uma abstração rasa.
### Decisão 4 — prova de responsividade e compatibilidade
**Opções consideradas:**
- A. Apenas snapshots/static markup e inspeção manual.
- B. Unit/static markup + Playwright medindo o named container em cada corte + interação real com sidebar/chat + projeto WebKit focado.
- C. Expandir toda a suíte campaign para uma matriz permanente Chromium/WebKit.
**Recomendação:** B — prova o mecanismo pelo tamanho do container, não por proxy de viewport, e cobre sticky/container queries em WebKit sem duplicar a suíte inteira. O spec focado roda no projeto Chromium já existente e em um projeto WebKit dedicado; CI instala somente os dois engines necessários.
**Rejeitadas:** A não detecta overflow nem erros de corte; C aumenta muito o tempo/custo de CI para uma necessidade localizada. WebKit é a automação disponível no Linux para compatibilidade Safari; uma checagem em Safari/iPad físico continua sendo validação de lançamento, não gate reproduzível deste agente.
### Componentes / mudanças
- **`parseCampaignHiddenColumns` / `serializeCampaignHiddenColumns` / resolver de defaults** (`src/lib/campaignColumnVisibility.ts`): preservar chave ausente versus vazio explícito, declarar o sentinel reservado e o preset de Municípios (`goalCoverage`, `lastSignal`) sem alterar defaults das outras listas.
- **`readCampaignColumnVisibility`** (`src/utilities/campaignColumnVisibilityCookie.ts`): aplicar preset somente quando a entrada da lista está ausente; devolver `[]` quando o sentinel registra “todas visíveis”.
- **`CampaignColumnPicker`** (`src/components/campaign/shared/CampaignColumnPicker.tsx`): manter o fluxo atual; `Restaurar todas` já chama `apply([])` e passará a persistir vazio explícito por efeito do serializer. Ajustar comentários/testes, não criar estado novo.
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`):
- envolver cards+tabela em `@container/municipality-list` com seletor estável de teste;
- reordenar colunas para Nome, 2022, 2026, Nível, Classe, Assessor, Tendência, Liderança, Dobradinha, Cobertura, Sinal, Ações;
- mover `TerritoryLink` para a segunda linha do nome e retirar `region` da tabela/picker;
- declarar `MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'` localmente;
- declarar classes C0–C6 como strings literais, reutilizadas em head/cell;
- calcular ocultação manual uma vez e incluir `actions` somente quando necessário;
- remover `overflow-x-auto` local e manter o wrapper sem scroller horizontal;
- preservar legenda/caption, inclusive `sort=region` ativo.
- **`MunicipalityListMobileCards`** (`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`): trocar somente `md:hidden` pela variante do named container; conteúdo/interação ficam intactos.
- **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/MunicipalityListTrendControl.tsx`): seam tipado de trigger adaptativo para a tabela; uma instância contém ícone compacto e badge/readout completo alternados por CSS. O botão continua com nome acessível textual e sem depender apenas de cor.
- **`MunicipalityListSignalControl`** (`src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): manter o slot `children`; o chamador fornece, na mesma instância, trigger compacto e `SignalAgeReadout` completo. Em `actions`, fornecer somente o trigger compacto apropriado.
- **`MunicipalityListLevelControl` / `MunicipalityLevelBadge`** (`src/components/campaign/municipality/`): a face vazia na célula de tabela passa a `—`, preservando “Sem nível” no `aria-label`, filtro e formulários/cards.
- **Labels e URL** (`src/utilities/municipality/municipalityLabels.ts`, `src/utilities/municipality/municipalityListUrl.ts`): retirar `region` apenas de `MunicipalityListColumnId`/records de coluna; manter `region` em sort/parser/serializer/resumo. Separar label de picker (`Estimativa 2026`) do head telegráfico (`2026`) e usar singular nos demais heads aprovados.
- **Filtros** (`MunicipalityFilters.tsx`, `municipalityOmnibox.ts`): sem mudança comportamental; regressões garantem `regionFilterOptions`, chips e `?region=`.
- **Migration:** sem migration.
- **Access / Consent:** nenhuma mudança; a composição por papel continua usando `isStaffView` e `isCampaignUnrestricted` existentes.
- **UI:** Impeccable Adapt, modo Operate. Shape já está fixado pela matriz; execução segue craft → critique → polish em duas rodadas visuais no máximo. Reusar tokens e shells existentes; sem redesign.
- **Documentação:** entrada curta em `docs/CHANGELOG-AGENTS.md`; não alterar `docs/ARCHITECTURE.md`, pois nenhuma fronteira muda.
### Dados → forma
- **Forma escolhida:** tabela essencial + disclosure progressivo por coluna, com cards no container estreito.
- **Por quê:** nome/2022/2026/nível/classe sustentam decisão imediata; responsabilidade e rede entram à medida que o painel comporta; trend/sinal preservam ação por trigger compacto.
- **Rejeitadas:** scroll horizontal mantém informação mas esconde contexto fora da dobra; tabela fixa reduz legibilidade; cards em toda largura desperdiçam densidade operacional.
## Fases verificáveis
1. **Tracer: preferência + estrutura local (~30%)**
- Evoluir parser/serializer/defaults com unit tests de round-trip e legado.
- Reordenar/remover `region`, criar named container, trocar cards↔tabela em 48rem e aplicar C0 essencial.
- Rodar testes unitários focados e `pnpm gate:fast`.
2. **Controles adaptativos + matriz completa (~35%)**
- Implementar `actions`, factories únicas e estados compacto/completo.
- Aplicar C1–C6 simetricamente em heads/cells, headers curtos, nome+território e nível vazio.
- Craft com conteúdo real/nomes longos; calibrar cortes uma única vez por evidência de `scrollWidth`.
3. **Browser + compatibilidade (~25%)**
- Spec Playwright focado com larguras 1px antes/depois de 48/54/60/66/72/78/84rem.
- Medir `scrollWidth <= clientWidth`, visibilidade cards/tabela, ordem/entrada, defaults, `actions` e uma instância por controle dentro da tabela.
- Remover largura forçada e validar que sidebar e chat alteram a etapa sem mudar a viewport.
- Rodar o mesmo spec em Chromium e WebKit; incluir cenário de viewport iPad para sticky/touch targets.
4. **Polish e gates (~10%)**
- Critique/polish em lote, teclado/foco/nomes acessíveis e alvo mínimo de 44px.
- Atualizar changelog.
- `pnpm gate:fast`, testes E2E focados, `pnpm exec knip`, `pnpm check:cycles`; entrega via `pnpm push`.
## Verificação prevista
### Unit/static markup
- `tests/unit/campaignColumnVisibility.unit.spec.ts`: ausente → sem chave; sentinel → chave com `[]`; round-trip explícito; entradas legadas e outras listas intactas.
- `tests/unit/campaignColumnPicker.unit.spec.ts`: “Restaurar todas” escreve `municipios:__none__`; toggles partem do preset recebido e persistem uma única sessão.
- `tests/unit/campaignComponents.unit.spec.ts`: ordem de headers por papel, `region` fora da tabela/picker, território sob nome, `actions` fora do picker, nível nulo `—`, classes simétricas e quantidade de triggers por cenário manual dentro do `<table>`.
- `tests/unit/municipalityList.unit.spec.ts` e `municipalityOmnibox.unit.spec.ts`: `sort=region`, resumo, filtros/chips/serialização continuam válidos.
### Browser/E2E
- Novo spec focado `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts`.
- Named container selecionável por atributo estável; largura controlada diretamente para provar os cortes.
- Defaults sem cookie: Cobertura/Sinal ocultos e ação de Sinal disponível; sentinel explícito: todas visíveis após reload.
- Cada faixa prova headers esperados, mudança compacta/completa e ausência de overflow.
- Sidebar/chat são acionados pelos controles reais; o teste compara a largura medida e a etapa resultante.
- Chromium desktop + WebKit focado; viewport iPad no cenário sticky/touch.
### Fonte oficial
- Tailwind CSS 4 container queries: `@container/{name}`, variantes nomeadas e valores arbitrários `@min-[…]`: https://tailwindcss.com/docs/responsive-design#container-queries
## Rabbit holes / Não escopo (engenharia)
- Não criar estado React, `ResizeObserver`, `matchMedia` ou hook de largura.
- Não adicionar política responsiva genérica a `CampaignTable` nem alterar `ui/Table` globalmente.
- Não desmontar a árvore cards/tabela com JavaScript; a montagem única é garantida entre coluna e `actions` na tabela.
- Não otimizar loaders por coluna visível.
- Não alterar conteúdo dos cards, o omnibox ou contratos de URL.
- Não converter toda a suíte E2E para uma matriz cross-browser.
- Não criar novos ícones/linguagem visual fora dos triggers adaptativos necessários.
## Riscos e mitigação
- **Tailwind não emitir classes dinâmicas:** manter todas as variantes C0–C6 como strings literais completas no arquivo; typecheck e build comprovam geração.
- **Head/cell divergirem:** usar um record local único por coluna e testes estáticos que verificam a mesma variante nos dois lados.
- **Overflow por conteúdo real, especialmente relações:** testar nomes longos e várias relações; compactar somente trend/sinal; calibrar cortes em uma rodada medida, sem loop de microajustes.
- **Sticky left em container sem scroller próprio:** testar Chromium/WebKit; manter fundo/z-index existentes e medir overflow do container.
- **Sentinel vazar como coluna:** parser reconhece o valor antes da validação de IDs; serializer nunca o aceita como entrada de usuário; tests pinam que `hiddenColumnIds` recebe `[]`.
- **Default novo parecer escolha explícita:** ausência aplica preset, mas a primeira interação escreve uma entrada explícita; “Restaurar todas” escreve sentinel e sobrevive a reload.
- **Custo CI WebKit:** projeto restrito ao spec B158, não à suíte campaign inteira.
- **Safari/iPad físico indisponível no ambiente Linux:** WebKit automatizado é gate técnico; inspeção física permanece checklist de lançamento, explicitamente não alegada como executada.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto.
- [ ] Invariantes AGENTS/engineering-standards preservados; sem schema, DB, access ou Consent.
- [ ] Preferência manual e capacidade do container permanecem dimensões independentes.
- [ ] Cada controle Trend/Sinal é montado no máximo uma vez por linha dentro da tabela.
- [ ] Testes unit/static cobrem cookie, papel, DOM e contratos URL/filtro.
- [ ] E2E mede container, overflow, shell real e compatibilidade Chromium/WebKit.
- [ ] Gate rápido, dead-code/cycles e push canônico previstos.
## Self-score decision-quality
1. Decisões caras têm alternativas rejeitadas: **1/1**.
2. Abordagem cabe no appetite: **1/1**.
3. Rabbit holes nomeados: **1/1**.
4. Depth check reusa owners/shells/helpers: **1/1**.
5. Outcome e lockdowns da intenção preservados: **1/1**.
**Total: 5/5.**

View file

@ -1,4 +1,4 @@
import { SearchXIcon } from 'lucide-react'
import { ActivityIcon, CircleAlertIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import {
@ -69,6 +69,10 @@ import {
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import {
isMunicipalitySignalCold,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type {
EligibleAdvisorOption,
EligibleLeadershipOption,
@ -155,8 +159,48 @@ const advisorEntries = (
* Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22
* description key — typed instead of widening to `string`.
*/
type MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'
type MunicipalityColumn = CampaignTableColumn<MunicipalityListViewModel> & {
id: MunicipalityListColumnId
id: MunicipalityTableColumnId
}
const responsiveColumnClassName = {
advisors: 'hidden @min-[54rem]/municipality-list:table-cell',
leaderships: 'hidden @min-[66rem]/municipality-list:table-cell',
stateDeputies: 'hidden @min-[72rem]/municipality-list:table-cell',
goalCoverage: 'hidden @min-[78rem]/municipality-list:table-cell',
} as const satisfies Partial<Record<MunicipalityTableColumnId, string>>
const MunicipalityListSignalTrigger = ({
lastSignalAt,
presentation,
}: {
lastSignalAt: string | null
presentation: 'compact' | 'adaptive'
}) => {
const isCold = isMunicipalitySignalCold(municipalitySignalAgeInDays(lastSignalAt))
const Icon = isCold ? CircleAlertIcon : ActivityIcon
const compact = (
<Icon
className={cn(
'size-4',
isCold ? 'text-estimate-pending-foreground' : 'text-muted-foreground',
)}
aria-hidden="true"
/>
)
if (presentation === 'compact') return compact
return (
<>
<span className="@min-[84rem]/municipality-list:hidden">{compact}</span>
<span className="hidden @min-[84rem]/municipality-list:block">
<SignalAgeReadout lastSignalAt={lastSignalAt} layout="table" />
</span>
</>
)
}
const municipalityListColumns = ({
@ -174,326 +218,373 @@ const municipalityListColumns = ({
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
}: MunicipalityListProps): Array<MunicipalityColumn> => [
{
id: 'name',
label: municipalityColumnLabels.name,
mandatory: true,
head: (
<MunicipalitySortableHead
state={state}
sortKey="name"
filterParam="name"
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
description={municipalityColumnDescriptions.name}
className="sticky left-0 z-20 min-w-56 bg-background"
columnVisibility,
}: MunicipalityListProps): Array<MunicipalityColumn> => {
const manuallyHidden = isStaffView ? new Set(columnVisibility.hiddenColumnIds) : new Set<string>()
const trendIsHidden = manuallyHidden.has('trend')
const signalIsHidden = manuallyHidden.has('lastSignal')
const renderTrendControl = (
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
) => (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="popover"
triggerPresentation={triggerPresentation}
/>
)
const renderSignalControl = (
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
) => (
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="popover"
formAction={signalFormAction}
>
<MunicipalityListSignalTrigger
lastSignalAt={municipality.lastSignalAt}
presentation={triggerPresentation}
/>
),
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
cell: (municipality) => (
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{municipality.name}
</Link>
{municipality.priority === 'alta' && isStaffView ? <MunicipalityPriorityIndicator /> : null}
</div>
),
},
{
id: 'region',
label: municipalityColumnLabels.region,
head: (
<MunicipalitySortableHead
state={state}
sortKey="region"
filterParam="region"
filterOptions={columnFilterOptions.region}
description={municipalityColumnDescriptions.region}
/>
),
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (municipality) => <TerritoryLink region={municipality.region} />,
},
{
id: 'votos',
label: municipalityColumnLabels.votos,
head: (
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
description={municipalityColumnDescriptions.votos}
/>
),
cellClassName: 'text-right',
cell: (municipality) =>
municipality.votePosition2022 ? (
<MunicipalityVotePositionReadout position={municipality.votePosition2022} layout="table" />
) : (
<span className="text-muted-foreground">—</span>
</MunicipalityListSignalControl>
)
return [
{
id: 'name',
label: municipalityColumnLabels.name,
mandatory: true,
head: (
<MunicipalitySortableHead
state={state}
sortKey="name"
filterParam="name"
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
description={municipalityColumnDescriptions.name}
className="sticky left-0 z-20 w-52 max-w-52 whitespace-normal bg-background"
/>
),
},
...(isStaffView
? ([
{
// Sits right after "2022": both read the same TSE artifact, and the
// class is the one-word summary of the votes beside it.
id: 'classe',
label: municipalityColumnLabels.classe,
head: (
<MunicipalitySortableHead
state={state}
sortKey="classe"
filterParam="class"
description={municipalityColumnDescriptions.classe}
/>
),
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
// The factors behind the label — the cell keeps them as `sr-only`
// text, so this stays a redundant affordance (see `cellTooltip`).
cellTooltip: (municipality) =>
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors),
},
{
// Right after "Classe": the pair reads diagnosis then decision.
id: 'level',
label: municipalityColumnLabels.level,
head: (
<MunicipalitySortableHead
state={state}
sortKey="nivel"
filterParam="level"
description={municipalityColumnDescriptions.level}
/>
),
cell: (municipality) =>
canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="popover"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="table"
cellClassName: 'sticky left-0 z-[5] w-52 max-w-52 whitespace-normal bg-background',
cell: (municipality) => (
<div className="flex min-w-0 max-w-52 flex-col gap-0.5">
<div className="flex min-w-0 items-start gap-1.5">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="line-clamp-2 min-h-11 min-w-0 flex-1 py-2 font-medium text-primary underline-offset-4 hover:underline"
>
{municipality.name}
</Link>
{municipality.priority === 'alta' && isStaffView ? (
<span className="mt-2 shrink-0">
<MunicipalityPriorityIndicator />
</span>
) : null}
</div>
<span className="block truncate text-xs text-muted-foreground">
<TerritoryLink region={municipality.region} />
</span>
</div>
),
},
{
id: 'votos',
label: municipalityColumnLabels.votos,
head: (
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
description={municipalityColumnDescriptions.votos}
/>
),
cellClassName: 'text-right',
cell: (municipality) =>
municipality.votePosition2022 ? (
<MunicipalityVotePositionReadout
position={municipality.votePosition2022}
layout="table"
/>
) : (
<span className="text-muted-foreground">—</span>
),
},
...(isStaffView
? ([
{
id: 'expectedVotes',
label: municipalityColumnLabels.expectedVotes,
head: (
<MunicipalitySortableHead
state={state}
sortKey="expectedVotes"
align="center"
description={municipalityColumnDescriptions.expectedVotes}
>
2026
</MunicipalitySortableHead>
),
cellClassName: 'relative overflow-visible align-middle text-center tabular-nums',
cell: (municipality) => (
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="popover"
/>
</div>
),
},
{
// The declared investment follows the 2026 estimate: diagnosis and
// decision stay together before the derived territorial class.
id: 'level',
label: municipalityColumnLabels.level,
head: (
<MunicipalitySortableHead
state={state}
sortKey="nivel"
filterParam="level"
description={municipalityColumnDescriptions.level}
/>
),
// The editable cell wraps its own trigger in a tooltip; declaring it
// here too would double it up on the same gesture (as in `advisors`).
cellTooltip: (municipality) =>
canMoveEngagementLevel || !municipality.engagementLevel
? null
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
.filter(Boolean)
.join(' — '),
},
{
id: 'advisors',
label: municipalityColumnLabels.advisors,
head: (
<MunicipalitySortableHead
state={state}
sortKey="coverage"
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
description={municipalityColumnDescriptions.advisors}
/>
),
cell: (municipality) =>
isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={municipality.priority === 'alta'}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="popover"
/>
) : (
<MunicipalityAdvisorAvatarStack
advisors={advisorEntries(municipality, advisorNamesById)}
isPriority={municipality.priority === 'alta'}
cell: (municipality) =>
canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="popover"
/>
) : municipality.engagementLevel ? (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="table"
/>
) : (
<span className="text-muted-foreground">—</span>
),
// The editable cell wraps its own trigger in a tooltip; declaring it
// here too would double it up on the same gesture (as in `advisors`).
cellTooltip: (municipality) =>
canMoveEngagementLevel || !municipality.engagementLevel
? null
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
.filter(Boolean)
.join(' — '),
},
{
id: 'classe',
label: municipalityColumnLabels.classe,
head: (
<MunicipalitySortableHead
state={state}
sortKey="classe"
filterParam="class"
description={municipalityColumnDescriptions.classe}
/>
),
// The coordinator's cell already wraps its own Popover trigger in a
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
// here too would double it up on the same gesture.
cellTooltip: (municipality) =>
isCoordinator
? null
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
},
...(isCampaignUnrestricted
? ([
{
// B157 — "who doubles with us here", next to "who takes care of
// it": the advisors-style avatar stack + hover detail, with an
// editor (Popover on desktop / Drawer on mobile) that also
// creates dobradinhas inline. Coordinator + candidate only.
id: 'stateDeputies',
label: municipalityColumnLabels.stateDeputies,
head: (
<CampaignTableHead description={municipalityColumnDescriptions.stateDeputies}>
{municipalityColumnLabels.stateDeputies}
</CampaignTableHead>
),
cellClassName: 'max-w-56 whitespace-normal',
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
] satisfies Array<MunicipalityColumn>)
: []),
{
// Right after "Assessores": the two relations of "who" in the
// município sit together, and both are editable by all staff (the
// advisor only within their portfolio, enforced server-side).
id: 'leaderships',
label: municipalityColumnLabels.leaderships,
// No sort, no filter — the B22 explanation rides on the plain head.
head: (
<CampaignTableHead description={municipalityColumnDescriptions.leaderships}>
{municipalityColumnLabels.leaderships}
</CampaignTableHead>
),
cell: (municipality) => (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
/>
),
},
{
id: 'trend',
label: municipalityColumnLabels.trend,
head: (
<MunicipalitySortableHead
state={state}
sortKey="trend"
filterParam="trend"
description={municipalityColumnDescriptions.trend}
/>
),
cell: (municipality) => (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="popover"
/>
),
},
{
id: 'expectedVotes',
label: municipalityColumnLabels.expectedVotes,
head: (
<MunicipalitySortableHead
state={state}
sortKey="expectedVotes"
align="center"
description={municipalityColumnDescriptions.expectedVotes}
/>
),
cellClassName: 'relative overflow-visible align-middle text-center',
cell: (municipality) => (
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
// The factors behind the label — the cell keeps them as `sr-only`
// text, so this stays a redundant affordance (see `cellTooltip`).
cellTooltip: (municipality) =>
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors),
},
{
id: 'advisors',
label: municipalityColumnLabels.advisors,
head: (
<MunicipalitySortableHead
state={state}
sortKey="coverage"
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
description={municipalityColumnDescriptions.advisors}
className={responsiveColumnClassName.advisors}
/>
),
cellClassName: responsiveColumnClassName.advisors,
cell: (municipality) =>
isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={municipality.priority === 'alta'}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="popover"
/>
) : (
<MunicipalityAdvisorAvatarStack
advisors={advisorEntries(municipality, advisorNamesById)}
isPriority={municipality.priority === 'alta'}
/>
),
// The coordinator's cell already wraps its own Popover trigger in a
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
// here too would double it up on the same gesture.
cellTooltip: (municipality) =>
isCoordinator
? null
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
},
{
id: 'trend',
label: municipalityColumnLabels.trend,
head: (
<MunicipalitySortableHead
state={state}
sortKey="trend"
filterParam="trend"
description={municipalityColumnDescriptions.trend}
/>
),
cell: (municipality) => renderTrendControl(municipality, 'adaptive'),
},
{
id: 'leaderships',
label: municipalityColumnLabels.leaderships,
head: (
<CampaignTableHead
description={municipalityColumnDescriptions.leaderships}
className={responsiveColumnClassName.leaderships}
>
{municipalityColumnLabels.leaderships}
</CampaignTableHead>
),
cellClassName: responsiveColumnClassName.leaderships,
cell: (municipality) => (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
/>
</div>
),
},
{
id: 'lastSignal',
label: municipalityColumnLabels.lastSignal,
head: (
<MunicipalitySortableHead
state={state}
sortKey="frescor"
description={municipalityColumnDescriptions.lastSignal}
>
{/* The one header that does NOT inherit its sort label: the column
shows the signal, `frescor` sorts by how old it is. */}
{municipalityColumnLabels.lastSignal}
</MunicipalitySortableHead>
),
cell: (municipality) => (
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="popover"
formAction={signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
</MunicipalityListSignalControl>
),
},
{
id: 'goalCoverage',
label: municipalityColumnLabels.goalCoverage,
head: (
<MunicipalitySortableHead
state={state}
sortKey="deficit"
description={municipalityColumnDescriptions.goalCoverage}
/>
),
cell: (municipality) => (
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
),
},
] satisfies Array<MunicipalityColumn>)
: ([
{
id: 'lastUpdateAt',
label: municipalityColumnLabels.lastUpdateAt,
head: (
<MunicipalitySortableHead
state={state}
sortKey="lastUpdateAt"
description={municipalityColumnDescriptions.lastUpdateAt}
/>
),
cell: (municipality) =>
municipality.lastUpdateAt
? dateFormatter.format(new Date(municipality.lastUpdateAt))
: 'Sem atualização',
},
] satisfies Array<MunicipalityColumn>)),
]
),
},
...(isCampaignUnrestricted
? ([
{
id: 'stateDeputies',
label: municipalityColumnLabels.stateDeputies,
head: (
<CampaignTableHead
description={municipalityColumnDescriptions.stateDeputies}
className={responsiveColumnClassName.stateDeputies}
>
{municipalityColumnLabels.stateDeputies}
</CampaignTableHead>
),
cellClassName: cn(
responsiveColumnClassName.stateDeputies,
'max-w-56 whitespace-normal',
),
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
] satisfies Array<MunicipalityColumn>)
: []),
{
id: 'goalCoverage',
label: municipalityColumnLabels.goalCoverage,
head: (
<MunicipalitySortableHead
state={state}
sortKey="deficit"
description={municipalityColumnDescriptions.goalCoverage}
className={responsiveColumnClassName.goalCoverage}
/>
),
cellClassName: responsiveColumnClassName.goalCoverage,
cell: (municipality) => (
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
),
},
{
id: 'lastSignal',
label: municipalityColumnLabels.lastSignal,
head: (
<MunicipalitySortableHead
state={state}
sortKey="frescor"
description={municipalityColumnDescriptions.lastSignal}
>
{municipalityColumnLabels.lastSignal}
</MunicipalitySortableHead>
),
cell: (municipality) => renderSignalControl(municipality, 'adaptive'),
},
...(trendIsHidden || signalIsHidden
? ([
{
id: 'actions',
label: 'Ações',
mandatory: true,
head: (
<CampaignTableHead align="right">
<span className="sr-only">Ações</span>
</CampaignTableHead>
),
cellClassName: 'text-right',
cell: (municipality) => (
<div className="flex items-center justify-end gap-1">
{trendIsHidden ? renderTrendControl(municipality, 'compact') : null}
{signalIsHidden ? renderSignalControl(municipality, 'compact') : null}
</div>
),
},
] satisfies Array<MunicipalityColumn>)
: []),
] satisfies Array<MunicipalityColumn>)
: ([
{
id: 'lastUpdateAt',
label: municipalityColumnLabels.lastUpdateAt,
head: (
<MunicipalitySortableHead
state={state}
sortKey="lastUpdateAt"
description={municipalityColumnDescriptions.lastUpdateAt}
/>
),
cell: (municipality) =>
municipality.lastUpdateAt
? dateFormatter.format(new Date(municipality.lastUpdateAt))
: 'Sem atualização',
},
] satisfies Array<MunicipalityColumn>)),
]
}
export const municipalityListPickerColumns = ({
isStaffView,
@ -504,25 +595,23 @@ export const municipalityListPickerColumns = ({
>): CampaignColumnPickerColumn[] => {
const base: CampaignColumnPickerColumn[] = [
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
{ id: 'region', label: municipalityColumnLabels.region },
{ id: 'votos', label: municipalityColumnLabels.votos },
]
if (!isStaffView) return base
return [
...base,
{ id: 'classe', label: municipalityColumnLabels.classe },
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
{ id: 'level', label: municipalityColumnLabels.level },
{ id: 'classe', label: municipalityColumnLabels.classe },
{ id: 'advisors', label: municipalityColumnLabels.advisors },
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
...(isCampaignUnrestricted
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
: []),
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
{ id: 'lastUpdateAt', label: municipalityColumnLabels.lastUpdateAt },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
]
}
@ -554,40 +643,42 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
return (
<MunicipalityAdvisorCreateProvider>
<MunicipalityLeadershipCreateProvider>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
isCampaignUnrestricted={isCampaignUnrestricted}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
stateDeputyOptions={stateDeputyOptions}
stateDeputyCommitAction={stateDeputyCommitAction}
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
<div data-container="municipality-list" className="@container/municipality-list">
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
isCampaignUnrestricted={isCampaignUnrestricted}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
stateDeputyOptions={stateDeputyOptions}
stateDeputyCommitAction={stateDeputyCommitAction}
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable
className="hidden overflow-visible md:block"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
</>
}
columns={columns}
columnVisibility={props.columnVisibility}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable
className="hidden overflow-visible @min-[48rem]/municipality-list:block"
containerClassName="overflow-x-auto supports-[container-type:inline-size]:overflow-x-hidden"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
</>
}
columns={columns}
columnVisibility={props.columnVisibility}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
</div>
</MunicipalityLeadershipCreateProvider>
</MunicipalityAdvisorCreateProvider>
)

View file

@ -248,12 +248,17 @@ export const MunicipalityListLevelControl = ({
preventPopoverAutoFocus
trigger={
// The sheet has no hover to carry the rest, so the trigger spells the
// level out there and stays the bare numeral in the table.
<MunicipalityLevelBadge
level={currentLevel}
note={isSheet ? currentNote : null}
layout={isSheet ? 'card' : 'table'}
/>
// level out there. In the table, absence follows the shared cell pattern
// (`—`) while the button's aria-label still announces "Sem nível".
currentLevel || isSheet ? (
<MunicipalityLevelBadge
level={currentLevel}
note={isSheet ? currentNote : null}
layout={isSheet ? 'card' : 'table'}
/>
) : (
<span className="text-muted-foreground">—</span>
)
}
>
<div className="flex flex-col gap-3">

View file

@ -83,7 +83,10 @@ export const MunicipalityListMobileCards = ({
signalFormAction,
emptySlot,
}: MunicipalityListMobileCardsProps) => (
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
<div
data-view="mobile-cards"
className="flex flex-col gap-4 @min-[48rem]/municipality-list:hidden"
>
{municipalities.length === 0 ? emptySlot : null}
{municipalities.map((municipality) => {
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(

View file

@ -78,7 +78,7 @@ export const MunicipalityListSignalControl = ({
triggerLabel={`Registrar sinal em ${municipalityName} — ${frescorLabel}`}
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando sinal…' : ''}
triggerClassName="text-left"
triggerClassName="min-w-11 text-left"
contentClassName="w-80"
trigger={children}
footer={

View file

@ -1,5 +1,6 @@
'use client'
import { CircleHelpIcon, MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import type {
@ -45,14 +46,22 @@ type MunicipalityListTrendControlProps = {
status: MunicipalityListSavedPoliticalTrend['status']
trendNote: string | null
variant: CampaignCellEditOverlayVariant
triggerPresentation?: 'full' | 'compact' | 'adaptive'
}
const politicalTrendIcon = {
favoravel: TrendingUpIcon,
neutra: MinusIcon,
desfavoravel: TrendingDownIcon,
} as const
export const MunicipalityListTrendControl = ({
municipalityID,
municipalityName,
status,
trendNote,
variant,
triggerPresentation = 'full',
}: MunicipalityListTrendControlProps) => {
const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<
@ -88,6 +97,25 @@ export const MunicipalityListTrendControl = ({
const hasNote = Boolean(value.note)
const trendLabel = value.status ? politicalTrendLabels[value.status] : 'Não registrada'
const TrendIcon = value.status ? politicalTrendIcon[value.status] : CircleHelpIcon
const badgeVariant = value.status ? politicalTrendBadgeVariant[value.status] : 'outline'
const fullTrigger = <Badge variant={badgeVariant}>{trendLabel}</Badge>
const compactTrigger = (
<Badge variant={badgeVariant} className="size-7 justify-center p-0">
<TrendIcon className="size-4" aria-hidden="true" />
</Badge>
)
const trigger =
triggerPresentation === 'full' ? (
fullTrigger
) : triggerPresentation === 'compact' ? (
compactTrigger
) : (
<>
<span className="@min-[60rem]/municipality-list:hidden">{compactTrigger}</span>
<span className="hidden @min-[60rem]/municipality-list:inline-flex">{fullTrigger}</span>
</>
)
return (
<CampaignCellEditOverlay
@ -102,17 +130,12 @@ export const MunicipalityListTrendControl = ({
// `MunicipalityListSignalControl`'s.
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
triggerBusy={isPending}
triggerClassName={triggerPresentation === 'full' ? undefined : 'min-w-11'}
statusMessage={statusMessage}
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{value.note}</p> : null}
contentClassName="w-72 p-3"
preventPopoverAutoFocus
trigger={
value.status ? (
<Badge variant={politicalTrendBadgeVariant[value.status]}>{trendLabel}</Badge>
) : (
<Badge variant="outline">{trendLabel}</Badge>
)
}
trigger={trigger}
>
<div className="relative flex flex-col gap-3">
{isPending ? (

View file

@ -63,8 +63,13 @@ export const toCampaignColumnPickerColumns = (
const LIST_SEPARATOR = '|'
const LIST_ID_SEPARATOR = ':'
const COLUMN_SEPARATOR = '~'
const EXPLICIT_EMPTY_COLUMNS = '__none__'
const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[]>> = {
municipios: ['goalCoverage', 'lastSignal'],
}
const isCampaignListId = (value: string): value is CampaignListId =>
CAMPAIGN_LIST_IDS.some((listId) => listId === value)
@ -83,12 +88,19 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
// An id matching nothing on the table is inert, so the length cap above is
// the only bound worth having — a per-list one would also have to be
// mirrored in `serialize`, or writing 41 and reading back 40 would differ.
const encodedColumns = entry.slice(separatorIndex + 1)
if (encodedColumns === EXPLICIT_EMPTY_COLUMNS) {
parsed[listId] = []
continue
}
const columnIds = [
...new Set(
entry
.slice(separatorIndex + 1)
encodedColumns
.split(COLUMN_SEPARATOR)
.filter((columnId) => COLUMN_ID_PATTERN.test(columnId)),
.filter(
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
),
),
]
@ -100,11 +112,27 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string =>
CAMPAIGN_LIST_IDS.flatMap((listId) => {
const columnIds = (value[listId] ?? []).filter((columnId) => COLUMN_ID_PATTERN.test(columnId))
if (!columnIds.length) return []
return [`${listId}${LIST_ID_SEPARATOR}${columnIds.join(COLUMN_SEPARATOR)}`]
if (!Object.prototype.hasOwnProperty.call(value, listId)) return []
const columnIds = (value[listId] ?? []).filter(
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
)
const encodedColumns = columnIds.length
? columnIds.join(COLUMN_SEPARATOR)
: EXPLICIT_EMPTY_COLUMNS
return [`${listId}${LIST_ID_SEPARATOR}${encodedColumns}`]
}).join(LIST_SEPARATOR)
export const resolveCampaignColumnVisibility = (
listId: CampaignListId,
hiddenColumns: CampaignHiddenColumns,
): CampaignColumnVisibility => ({
listId,
hiddenColumnIds: Object.prototype.hasOwnProperty.call(hiddenColumns, listId)
? (hiddenColumns[listId] ?? [])
: (DEFAULT_HIDDEN_COLUMN_IDS[listId] ?? []),
})
/**
* Records one toggle. Hiding is stored (never the visible set), so a column
* shipped by a later item — E9's derived signals, E14's level — is visible to

View file

@ -5,6 +5,7 @@ import { cookies } from 'next/headers'
import {
CAMPAIGN_COLUMNS_COOKIE,
parseCampaignHiddenColumns,
resolveCampaignColumnVisibility,
type CampaignColumnVisibility,
type CampaignListId,
} from '@/lib/campaignColumnVisibility'
@ -20,5 +21,5 @@ export const readCampaignColumnVisibility = async (
const cookieStore = await cookies()
const raw = cookieStore.get(CAMPAIGN_COLUMNS_COOKIE)?.value
return { listId, hiddenColumnIds: parseCampaignHiddenColumns(raw)[listId] ?? [] }
return resolveCampaignColumnVisibility(listId, parseCampaignHiddenColumns(raw))
}

View file

@ -182,7 +182,6 @@ export const formatMunicipalityGeographyLabel = (municipality: {
*/
export type MunicipalityListColumnId =
| 'name'
| 'region'
| 'votos'
| 'classe'
// Matches the column's own `id` and its `?level=` filter param; the SORT key
@ -214,8 +213,7 @@ const CLASS_COLUMN_DESCRIPTION =
* on props, so there is nothing to recompute per render.
*/
export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, string> = {
name: 'Nome do município e prioridade da campanha.',
region: 'Território de Identidade (Bahia) a que o município pertence.',
name: 'Nome do município, Território de Identidade e prioridade da campanha.',
votos: formatMunicipalityConcentrationHint(),
classe: CLASS_COLUMN_DESCRIPTION,
level: campaignConceptOneLiner('nivel-de-envolvimento'),
@ -224,7 +222,8 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
stateDeputies:
'Deputados estaduais com quem a campanha dobra neste município — coordenação e candidato editam clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),
expectedVotes:
'Estimativa de votos do candidato neste município em 2026. Ordena pelo cenário central, independente do cenário selecionado acima.',
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
// The scenario picker above the table is client state, so the server can
// only sort by one scenario — named here so the ordering never looks

View file

@ -101,21 +101,18 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
*/
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
name: municipalityListSortLabels.name,
region: municipalityListSortLabels.region,
/** Header is the bare year, under the "2022" group of the table. */
votos: 'Votação 2022',
classe: municipalityListSortLabels.classe,
expectedVotes: 'Estimativa 2026',
level: municipalityListSortLabels.nivel,
advisors: municipalityListSortLabels.coverage,
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
leaderships: 'Lideranças',
/** B157 — nova coluna entre Assessores e Tendência (grupo "rede"). */
stateDeputies: 'Dobradinhas',
classe: municipalityListSortLabels.classe,
advisors: 'Assessor',
trend: municipalityListSortLabels.trend,
expectedVotes: municipalityListSortLabels.expectedVotes,
/** The column shows the signal; `frescor` sorts by how old it is. */
lastSignal: 'Último sinal',
leaderships: 'Liderança',
stateDeputies: 'Dobradinha',
goalCoverage: municipalityListSortLabels.deficit,
/** The column shows the signal; `frescor` sorts by how old it is. */
lastSignal: 'Sinal',
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
}

View file

@ -25,6 +25,9 @@ test.describe('Seletor de colunas', () => {
// Crosses two heavy list routes; `/campanha/liderancas` only finishes
// compiling in dev when an authenticated request reaches its tree.
test.slow()
// B158 switches to cards below 48rem of content width. Give this picker
// regression enough content room to observe table headers directly.
await page.setViewportSize({ width: 1800, height: 900 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
@ -36,20 +39,33 @@ test.describe('Seletor de colunas', () => {
await campaign.login(page, email, password)
await page.goto('/campanha/municipios')
const regionHeader = page.getByRole('columnheader', { name: /Território/ })
await expect(regionHeader).toBeVisible()
const votesHeader = page.getByRole('columnheader', { name: /2022/ })
await expect(votesHeader).toBeVisible()
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
await picker.click()
await page.getByRole('checkbox', { name: 'Território' }).click()
// A fresh profile receives B158's two defaults. Restoring all records the
// deliberate empty preference instead of reverting to those defaults.
await page.getByRole('button', { name: 'Restaurar todas' }).click()
await page.keyboard.press('Escape')
await expect
.poll(
async () =>
(await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns')
?.value,
)
.toContain('municipios:__none__')
await picker.click()
await page.getByRole('checkbox', { name: 'Votação 2022' }).click()
// Closing flushes the batched cookie write and refreshes the RSC.
await page.keyboard.press('Escape')
await expect(regionHeader).toHaveCount(0, REFRESH)
await expect(votesHeader).toHaveCount(0, REFRESH)
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
await page.reload()
await expect(page.getByRole('columnheader', { name: /Território/ })).toHaveCount(0, REFRESH)
await expect(page.getByRole('columnheader', { name: /2022/ })).toHaveCount(0, REFRESH)
// The cookie carries one key per list: another list is untouched. The
// territories list is the honest neighbour here — its 27 rows come from
@ -63,7 +79,16 @@ test.describe('Seletor de colunas', () => {
await page.getByRole('button', { name: 'Restaurar todas' }).click()
await page.keyboard.press('Escape')
await expect(page.getByRole('columnheader', { name: /Território/ })).toBeVisible(REFRESH)
await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH)
await page.reload()
await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH)
await expect
.poll(
async () =>
(await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns')
?.value,
)
.toContain('municipios:__none__')
})
test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => {

View file

@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
import {
ensureLeasedConsent,
@ -15,6 +17,39 @@ import {
collectActionBoundingBoxes,
} from './helpers/actionGridGeometry.js'
const showAllMunicipalityColumns = (page: Page, url: string) =>
page.context().addCookies([
{
name: 'campaign_columns',
value: encodeURIComponent('municipios:__none__'),
url,
},
])
const municipalityContainer = (page: Page) =>
page.locator('[data-container="municipality-list"]').last()
const visibleMunicipalityButton = (page: Page, name: string) =>
municipalityContainer(page).getByRole('button', { name }).filter({ visible: true })
const ensureWideMunicipalityList = async (page: Page) => {
// The resizable panel hydrates independently from the RSC list and can
// change the container stage after a cell first becomes visible. These
// desktop cell journeys wait for the widest table stage; B158 exercises the
// rail transition itself in its dedicated spec.
await page.waitForLoadState('networkidle')
const container = municipalityContainer(page)
const width = await container.evaluate((element) => element.clientWidth)
const close = page.getByRole('button', { name: 'Fechar', exact: true }).filter({ visible: true })
// A collapsed Panel can leave its overflowing close button technically
// visible. Only click when the list itself proves the chat still consumes
// space; otherwise the click is a no-op against an already collapsed rail.
if (width < 78 * 16 && (await close.isVisible())) await close.click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeGreaterThanOrEqual(78 * 16)
}
/**
* Core municipality-model journeys per role: coordinator strategy editing, advisor
* scoping, staff declare/estimate privacy boundary, leader lockdown, and staff-only
@ -26,6 +61,14 @@ import {
*/
test.describe('Municípios — jornadas por papel', () => {
test.beforeEach(async ({ page }) => {
// B158 keys the table stages to content width and gives fresh profiles a
// compact default. These pre-B158 journeys exercise specific desktop
// cells, so make that prerequisite explicit instead of finding hidden
// copies in the always-mounted mobile-card tree.
await page.setViewportSize({ width: 2400, height: 1000 })
})
test('coordinator opens the municipalities list, edits strategy and assigns an advisor', async ({
campaign,
page,
@ -41,6 +84,7 @@ test.describe('Municípios — jornadas por papel', () => {
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
@ -96,16 +140,23 @@ test.describe('Municípios — jornadas por papel', () => {
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
// Name includes the município — a substring search can return neighbors
// (strict-mode flake when two "Editar assessores …" buttons match).
const advisorsTrigger = page.getByRole('button', {
name: `Editar assessores em ${municipality.name}`,
})
// A concurrent RSC navigation may briefly retain the previous segment.
// Scope the editor to the appended, active municipality container so a
// hidden copy cannot absorb Playwright's click retries until timeout.
const advisorsTrigger = visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name}`,
)
await expect(advisorsTrigger).toBeVisible()
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
@ -117,16 +168,12 @@ test.describe('Municípios — jornadas por papel', () => {
// No "Salvar" button in this popover: selecting the option auto-saves the
// delta and renders it as a removable chip immediately.
const chip = advisorsPopover.getByRole('button', { name: `Remover ${advisor.name}` })
// Click the option itself: the inner text generic does not always trigger
// the combobox selection (measured — the popover stayed on "selected: você").
// Retry loop for the pre-hydration flake class (same as
// `checkRadixWhenHydrated`): a click before React attaches is a silent
// no-op, so the probe retries until the chip sticks.
await expect(async () => {
await search.fill(searchNamePart)
await option.click({ timeout: 1_000 })
await expect(chip).toBeVisible({ timeout: 4_000 })
}).toPass({ timeout: 20_000 })
// Opening this client-only popover already proves hydration. Wait for the
// write itself: the chip and trigger are optimistic and do not prove the
// assignment reached the database before the reload below.
await search.fill(searchNamePart)
await Promise.all([expectPostResponse(page, '/campanha/municipios/advisors'), option.click()])
await expect(chip).toBeVisible()
// The popover stays open after the write (auto-save, not submit+close).
await expect(advisorsPopover).toBeVisible()
@ -143,11 +190,11 @@ test.describe('Municípios — jornadas por papel', () => {
// state, now holds the assignment.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page
.getByRole('button', {
name: `Editar assessores em ${municipality.name} — ${advisor.name}`,
})
.click()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name} — ${advisor.name}`,
).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${advisor.name}`,
@ -170,14 +217,17 @@ test.describe('Municípios — jornadas por papel', () => {
const newAdvisorName = fixtures.value('Novo Assessor')
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const advisorsTrigger = page.getByRole('button', {
name: `Editar assessores em ${municipality.name}`,
})
const advisorsTrigger = visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name}`,
)
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
await expect(advisorsPopover).toBeVisible()
@ -213,11 +263,11 @@ test.describe('Municípios — jornadas por papel', () => {
// and the assignment, not just the local optimistic state.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page
.getByRole('button', {
name: `Editar assessores em ${municipality.name} — ${newAdvisorName}`,
})
.click()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name} — ${newAdvisorName}`,
).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${newAdvisorName}`,
@ -241,14 +291,17 @@ test.describe('Municípios — jornadas por papel', () => {
const party = 'PCdoB'
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const deputiesTrigger = page.getByRole('button', {
name: `Editar dobradinhas em ${municipality.name}`,
})
const deputiesTrigger = visibleMunicipalityButton(
page,
`Editar dobradinhas em ${municipality.name}`,
)
await deputiesTrigger.click()
const deputiesPopover = page.locator('[data-slot="popover-content"]')
await expect(deputiesPopover).toBeVisible()
@ -286,7 +339,8 @@ test.describe('Municípios — jornadas por papel', () => {
// the assignment, not just the local optimistic state.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: `Editar dobradinhas em ${municipality.name}` }).click()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${deputyName} (${party})`,
@ -307,6 +361,7 @@ test.describe('Municípios — jornadas por papel', () => {
const note = fixtures.value('Vereador confirmou apoio local')
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
@ -349,18 +404,22 @@ test.describe('Municípios — jornadas por papel', () => {
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
// Anchored: a bare template regex on a municipality name collides with
// prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog),
// and `exact` no longer matches because the accessible name appends the
// current signal state ("— Sem sinal").
const signalTrigger = page.getByRole('button', {
name: new RegExp(`^Registrar sinal em ${municipality.name} —`),
})
const signalTrigger = municipalityContainer(page)
.getByRole('button', {
name: new RegExp(`^Registrar sinal em ${municipality.name} —`),
})
.filter({ visible: true })
await expect(signalTrigger).toBeVisible()
await signalTrigger.click()
@ -370,9 +429,13 @@ test.describe('Municípios — jornadas por papel', () => {
.getByRole('textbox', { name: 'Texto' })
.fill('Liderança local reportou visita adversária na feira.')
await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario')
await signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click()
await Promise.all([
expectPostResponse(page, '/campanha/municipios'),
signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click(),
])
await expect(page.getByText('Sinal registrado.', { exact: true })).toBeVisible()
// The toast is intentionally transient; the persisted freshness label is
// the stable success contract and must arrive after the server response.
await expect(
page.getByRole('button', {
name: `Registrar sinal em ${municipality.name} — hoje`,
@ -433,6 +496,7 @@ test.describe('Municípios — jornadas por papel', () => {
// Advisor scope: only the administered municipality shows up.
await campaign.login(page, advisor.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(
page.getByRole('link', { name: administered.name, exact: true }).first(),

View file

@ -0,0 +1,248 @@
import type { Locator, Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
const REM_IN_PIXELS = 16
const municipalityColumnsCookie = encodeURIComponent('municipios:__none__')
// During a concurrent RSC navigation Next may briefly retain the previous
// segment beside the incoming one. The current segment is appended last.
const municipalityContainer = (page: Page) =>
page.locator('[data-container="municipality-list"]').last()
const setContainerWidth = async (container: Locator, width: number) => {
await container.evaluate((element, nextWidth) => {
const htmlElement = element as HTMLElement
htmlElement.style.width = `${nextWidth}px`
htmlElement.style.maxWidth = `${nextWidth}px`
}, width)
await expect.poll(() => container.evaluate((element) => element.clientWidth)).toBe(width)
}
const visibleTriggerText = (trigger: Locator) =>
trigger.evaluate((element) => (element as HTMLElement).innerText.trim())
const visibleHeaders = (container: Locator) =>
container
.locator('thead th')
.evaluateAll((headers) =>
headers
.filter((header) => getComputedStyle(header).display !== 'none')
.map((header) => (header.textContent ?? '').replaceAll(/\s+/g, ' ').trim()),
)
const expectNoHorizontalOverflow = async (container: Locator) => {
await expect
.poll(() => container.evaluate((element) => element.scrollWidth <= element.clientWidth))
.toBe(true)
}
type ResponsiveCampaignFixture = {
fixtures: {
createCampaignUser: (
role: 'advisor' | 'coordinator',
input: { name: string },
) => Promise<{ email?: string | null; password: string }>
value: (prefix: string) => string
}
login: (page: Page, identifier: string, password: string) => Promise<void>
}
const loginWithAllColumns = async (
page: Page,
campaign: ResponsiveCampaignFixture,
role: 'advisor' | 'coordinator' = 'coordinator',
) => {
const user = await campaign.fixtures.createCampaignUser(role, {
name: campaign.fixtures.value(`Responsivo ${role}`),
})
await campaign.login(page, user.email!, user.password)
await page.context().addCookies([
{
name: 'campaign_columns',
value: municipalityColumnsCookie,
url: new URL(page.url()).origin,
},
])
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
await expect(municipalityContainer(page)).toBeVisible()
}
const expectedHeadersAt = (width: number) => {
if (width < 48 * REM_IN_PIXELS) return []
const headers = ['Município', '2022', '2026', 'Nível', 'Classe']
if (width >= 54 * REM_IN_PIXELS) headers.push('Assessores')
headers.push('Tendência')
if (width >= 66 * REM_IN_PIXELS) headers.push('Liderança')
if (width >= 72 * REM_IN_PIXELS) headers.push('Dobradinha')
if (width >= 78 * REM_IN_PIXELS) headers.push('Cobertura')
headers.push('Sinal')
return headers
}
test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
test('crosses every container threshold without horizontal scrolling', async ({
campaign,
page,
}) => {
test.slow()
await loginWithAllColumns(page, campaign)
const container = municipalityContainer(page)
const cards = container.locator('[data-view="mobile-cards"]')
const table = container.getByRole('table')
const trendTrigger = table.getByRole('button', { name: /^Editar tendência política/ }).first()
const signalTrigger = table.getByRole('button', { name: /^Registrar sinal em/ }).first()
// Test around (and on) the three binary switches: cards↔table (48rem),
// trend text (60rem), signal text (84rem). Container queries use min-width.
const widths = [
48 * REM_IN_PIXELS - 1,
48 * REM_IN_PIXELS,
48 * REM_IN_PIXELS + 1,
54 * REM_IN_PIXELS - 1,
54 * REM_IN_PIXELS + 1,
60 * REM_IN_PIXELS - 1,
60 * REM_IN_PIXELS,
60 * REM_IN_PIXELS + 1,
66 * REM_IN_PIXELS - 1,
66 * REM_IN_PIXELS + 1,
72 * REM_IN_PIXELS - 1,
72 * REM_IN_PIXELS + 1,
78 * REM_IN_PIXELS - 1,
78 * REM_IN_PIXELS + 1,
84 * REM_IN_PIXELS - 1,
84 * REM_IN_PIXELS,
84 * REM_IN_PIXELS + 1,
]
for (const width of widths) {
await test.step(`${width}px`, async () => {
await setContainerWidth(container, width)
if (width < 48 * REM_IN_PIXELS) {
await expect(cards).toBeVisible()
await expect(table).toBeHidden()
} else {
await expect(cards).toBeHidden()
await expect(table).toBeVisible()
expect(await visibleHeaders(container)).toEqual(expectedHeadersAt(width))
expect(await visibleTriggerText(trendTrigger)).toBe(
width >= 60 * REM_IN_PIXELS ? 'Não registrada' : '',
)
expect(await visibleTriggerText(signalTrigger)).toBe(
width >= 84 * REM_IN_PIXELS ? 'Sem sinal' : '',
)
}
await expectNoHorizontalOverflow(container)
})
}
})
test('sidebar and Sollinha change the column stage without changing the viewport', async ({
campaign,
page,
}) => {
test.slow()
// At 1600px the real desktop rails straddle the responsive stages: the
// collapsed sidebar adds a column and the open chat removes one. A wider
// viewport can leave all optional columns visible in both states, which
// would only prove that the container shrank, not that its query reacted.
await page.setViewportSize({ width: 1600, height: 900 })
await loginWithAllColumns(page, campaign)
const container = municipalityContainer(page)
const viewportBefore = page.viewportSize()
const widthWithSidebar = await container.evaluate((element) => element.clientWidth)
const headersWithSidebar = await visibleHeaders(container)
await page.getByRole('button', { name: 'Abrir ou fechar menu da campanha' }).click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeGreaterThan(widthWithSidebar)
await expect
.poll(async () => (await visibleHeaders(container)).length)
.toBeGreaterThan(headersWithSidebar.length)
const widthAfterSidebarToggle = await container.evaluate((element) => element.clientWidth)
const initiallyOpenChatClose = page
.getByRole('button', { name: 'Fechar', exact: true })
.filter({ visible: true })
if ((await initiallyOpenChatClose.count()) > 0) {
await initiallyOpenChatClose.click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeGreaterThan(widthAfterSidebarToggle)
}
const widthWithoutSidebar = await container.evaluate((element) => element.clientWidth)
const headersWithoutSidebar = await visibleHeaders(container)
await page
.getByRole('button', { name: 'Sollinha — Assistente virtual' })
.filter({ visible: true })
.click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeLessThan(widthWithoutSidebar)
await expect
.poll(async () => (await visibleHeaders(container)).length)
.toBeLessThan(headersWithoutSidebar.length)
const widthWithChat = await container.evaluate((element) => element.clientWidth)
const headersWithChat = await visibleHeaders(container)
await page
.getByRole('button', { name: 'Fechar', exact: true })
.filter({ visible: true })
.click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeGreaterThan(widthWithChat)
await expect
.poll(async () => (await visibleHeaders(container)).length)
.toBeGreaterThan(headersWithChat.length)
expect(page.viewportSize()).toEqual(viewportBefore)
await expectNoHorizontalOverflow(container)
})
test('keeps Dobradinha restricted even when an advisor has enough room', async ({
campaign,
page,
}) => {
await loginWithAllColumns(page, campaign, 'advisor')
const container = municipalityContainer(page)
await setContainerWidth(container, 84 * REM_IN_PIXELS + 1)
await expect(container.getByRole('columnheader', { name: /Assessores/ })).toBeVisible()
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toHaveCount(0)
await expectNoHorizontalOverflow(container)
})
test('keeps the pinned name and 44px edit targets on an iPad viewport', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 834, height: 1194 })
await loginWithAllColumns(page, campaign)
const container = municipalityContainer(page)
// Force the container past 48rem on the iPad viewport — the sidebar
// normally leaves too little content width for the table to appear.
await setContainerWidth(container, 48 * REM_IN_PIXELS + 1)
const table = container.getByRole('table')
await expect(table).toBeVisible()
const stickyPosition = await table
.getByRole('columnheader', { name: /Município/ })
.evaluate((element) => getComputedStyle(element).position)
expect(stickyPosition).toBe('sticky')
for (const trigger of [
table.getByRole('button', { name: /^Editar tendência política/ }).first(),
table.getByRole('button', { name: /^Registrar sinal em/ }).first(),
]) {
const box = await trigger.boundingBox()
expect(box?.width).toBeGreaterThanOrEqual(44)
expect(box?.height).toBeGreaterThanOrEqual(44)
}
await expectNoHorizontalOverflow(container)
})
})

View file

@ -22,7 +22,7 @@ let cookieJar = ''
const COLUMNS = [
{ id: 'name', label: 'Município', mandatory: true },
{ id: 'region', label: 'Território' },
{ id: 'goalCoverage', label: 'Cobertura' },
{ id: 'trend', label: 'Tendência' },
]
@ -73,7 +73,7 @@ describe('CampaignColumnPicker', () => {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
// Reading the next label takes longer than any debounce worth having:
// a second between checkboxes must still be one refresh, not two.
await vi.advanceTimersByTimeAsync(1_000)
@ -84,7 +84,7 @@ describe('CampaignColumnPicker', () => {
closeMenu()
expect(refresh).toHaveBeenCalledTimes(1)
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
} finally {
vi.useRealTimers()
}
@ -96,14 +96,14 @@ describe('CampaignColumnPicker', () => {
try {
render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
await vi.advanceTimersByTimeAsync(3_000)
// The idle timer is durability, not the commit: a slow reader who takes
// more than the interval between checkboxes must not pay a route render
// per column, which is the failure the 400 ms debounce was rejected for.
expect(cookieJar).toContain('campaign_columns=municipios:region')
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
expect(refresh).not.toHaveBeenCalled()
closeMenu()
@ -114,11 +114,11 @@ describe('CampaignColumnPicker', () => {
})
it('does not refresh a session that ends where it started', () => {
render(picker(['region']))
render(picker(['goalCoverage']))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
closeMenu()
expect(refresh).not.toHaveBeenCalled()
@ -130,10 +130,21 @@ describe('CampaignColumnPicker', () => {
// Radix reports no close event here, so without the unmount flush this
// choice would be lost to a sidebar navigation with no feedback at all.
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
unmount()
expect(cookieJar).toContain('campaign_columns=municipios:region')
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
})
it('serializes restoring every municipality column as an explicit empty choice', () => {
render(picker(['trend']))
openMenu()
fireEvent.click(screen.getByRole('button', { name: 'Restaurar todas' }))
closeMenu()
expect(cookieJar).toContain('campaign_columns=municipios:__none__')
expect(refresh).toHaveBeenCalledTimes(1)
})
it('never lets the mandatory column be unchecked', () => {
@ -150,7 +161,7 @@ describe('CampaignColumnPicker', () => {
const { rerender } = render(picker([]))
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
@ -158,8 +169,8 @@ describe('CampaignColumnPicker', () => {
openMenu()
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
// … and the RSC payload of the FIRST one lands inside it. Adopting it here
// would uncheck nothing and re-commit `region` alone, dropping `trend`.
rerender(picker(['region']))
// would uncheck nothing and re-commit `goalCoverage` alone, dropping `trend`.
rerender(picker(['goalCoverage']))
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
'false',
@ -167,19 +178,19 @@ describe('CampaignColumnPicker', () => {
closeMenu()
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
})
it('adopts the server payload once nothing is pending', async () => {
const { rerender } = render(picker([]))
rerender(picker(['region']))
rerender(picker(['goalCoverage']))
openMenu()
await waitFor(() =>
expect(
screen.getByRole('checkbox', { name: 'Território' }).getAttribute('aria-checked'),
).toBe('false'),
expect(screen.getByRole('checkbox', { name: 'Cobertura' }).getAttribute('aria-checked')).toBe(
'false',
),
)
expect(refresh).not.toHaveBeenCalled()
})

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
parseCampaignHiddenColumns,
resolveCampaignColumnVisibility,
resolveVisibleColumns,
serializeCampaignHiddenColumns,
toggleHiddenColumn,
@ -18,9 +19,13 @@ describe('parseCampaignHiddenColumns', () => {
})
})
it('shows the full table when the cookie is absent, empty or malformed', () => {
it('keeps an absent list distinct from an explicitly empty list', () => {
expect(parseCampaignHiddenColumns(undefined)).toEqual({})
expect(parseCampaignHiddenColumns('')).toEqual({})
expect(parseCampaignHiddenColumns('municipios:__none__')).toEqual({ municipios: [] })
})
it('ignores malformed entries instead of guessing', () => {
expect(parseCampaignHiddenColumns('garbage')).toEqual({})
expect(parseCampaignHiddenColumns(':kind')).toEqual({})
expect(parseCampaignHiddenColumns('municipios:')).toEqual({})
@ -42,12 +47,13 @@ describe('parseCampaignHiddenColumns', () => {
})
describe('serializeCampaignHiddenColumns', () => {
it('round-trips through parse and omits lists with nothing hidden', () => {
const value = { municipios: ['kind', 'trend'], demandas: ['requester'] }
it('round-trips hidden columns and explicit empty lists', () => {
const value = { municipios: ['kind', 'trend'], demandas: [] }
const serialized = serializeCampaignHiddenColumns(value)
expect(parseCampaignHiddenColumns(serialized)).toEqual(value)
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('')
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('municipios:__none__')
expect(serializeCampaignHiddenColumns({})).toBe('')
})
it('never emits a character that would break the cookie value', () => {
@ -57,6 +63,26 @@ describe('serializeCampaignHiddenColumns', () => {
})
})
describe('resolveCampaignColumnVisibility', () => {
it('applies compact defaults only when municipios is absent', () => {
expect(resolveCampaignColumnVisibility('municipios', {})).toEqual({
listId: 'municipios',
hiddenColumnIds: ['goalCoverage', 'lastSignal'],
})
expect(resolveCampaignColumnVisibility('municipios', { municipios: [] })).toEqual({
listId: 'municipios',
hiddenColumnIds: [],
})
})
it('keeps the existing empty default for every other list', () => {
expect(resolveCampaignColumnVisibility('liderancas', {})).toEqual({
listId: 'liderancas',
hiddenColumnIds: [],
})
})
})
describe('toggleHiddenColumn', () => {
it('hides, shows and never duplicates an id', () => {
const hidden = toggleHiddenColumn([], 'kind', false)

View file

@ -8,7 +8,10 @@ import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import {
MunicipalityList,
municipalityListPickerColumns,
} from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { TseZoneBadge } from '@/components/campaign/municipality/TseZoneBadge'
@ -62,6 +65,34 @@ const municipalityListDefaultProps = {
state: { page: 1 },
}
const responsiveMunicipality = stub<MunicipalityListViewModel>({
id: 1,
name: 'São Francisco do Conde com nome extenso',
slug: 'sao-francisco-do-conde',
kind: 'municipio',
city: 'São Francisco do Conde',
region: 'Metropolitano de Salvador',
ibgeCode: '2929206',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'sem_base',
territorialClassFactors: [],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
})
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
push: () => {},
replace: () => {},
@ -279,15 +310,24 @@ describe('campaign visual foundation', () => {
// B155: the Lideranças column chips the linked leaderships by name.
expect(html).toContain('Lideranças')
expect(html).toContain('Maria de Jesus')
// B22: every one of the 11 staff columns carries a header explanation —
// B22: every one of the 10 visible-by-default staff columns carries a header explanation —
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
// the mobile card's own class tooltip (B42) add triggers of their own.
const [, tbodyHtml = ''] = html.split('<tbody')
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
// B41: horizontal scroll table with a pinned Município column header.
// B158: the table is container-driven and clips instead of delegating to a
// horizontal scroller. Município remains pinned and owns the territory subline.
expect(html).toContain('data-container="municipality-list"')
expect(html).toContain('@container/municipality-list')
expect(html).toContain('@min-[48rem]/municipality-list:block')
expect(html).toContain('@min-[48rem]/municipality-list:hidden')
expect(html).toContain('data-slot="table-container"')
expect(html).toContain('supports-[container-type:inline-size]:overflow-x-hidden')
// overflow-x-auto is the fallback for browsers without container queries;
// the test above still proves the container-query path takes precedence.
expect(html).toContain('overflow-x-auto')
expect(theadHtml).toContain('Município')
expect(tbodyHtml).toContain('Seabra')
})
@ -338,11 +378,10 @@ describe('campaign visual foundation', () => {
}),
)
// B157: the new column's header explanation makes it 12 staff tooltips
// (11 with B155's Lideranças column + this one).
// B157/B158: Dobradinha adds one explanation to the 10-column staff baseline.
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(12)
expect(theadHtml).toContain('Dobradinhas')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
expect(theadHtml).toContain('Dobradinha')
// Avatar-stack display: initials circles + the names in the trigger's
// accessible label (the closed cell reads WHO is assigned without opening).
expect(html).toContain('>F<') // Fulano
@ -663,10 +702,113 @@ describe('campaign visual foundation', () => {
expect(html).not.toContain('Faltam')
expect(html).not.toContain('Prioritária')
expect(html).not.toContain(`aria-label="${municipalityPriorityIndicatorLabel}"`)
// B22: the leader view's 5 columns (name/region/kind/votos/lastUpdateAt)
// also carry header explanations.
// B22/B158: the leader view keeps only Município, 2022 and Atualização;
// all three carry header explanations.
const [theadHtml] = html.split('<tbody')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(4)
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(3)
})
describe('B158 responsive municipality columns', () => {
const renderResponsiveList = (hiddenColumnIds: string[] = []) =>
renderWithAppRouter(
createElement(MunicipalityList, {
municipalities: [responsiveMunicipality],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
...municipalityListDefaultProps,
isStaffView: true,
isCampaignUnrestricted: true,
columnVisibility: { listId: 'municipios', hiddenColumnIds },
}),
)
it('keeps the table order, territory subline, and symmetric container-query classes', () => {
const html = renderResponsiveList()
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
const tbodyHtml = html.slice(html.indexOf('<tbody'), html.indexOf('</tbody>'))
const labels = [
'Município',
'2022',
'2026',
'Nível',
'Classe',
'Assessor',
'Tendência',
'Liderança',
'Dobradinha',
'Cobertura',
'Sinal',
]
let previousIndex = -1
for (const label of labels) {
const labelIndex = theadHtml.indexOf(label)
expect(labelIndex, `${label} deve aparecer depois da coluna anterior`).toBeGreaterThan(
previousIndex,
)
previousIndex = labelIndex
}
expect(theadHtml).not.toContain('Território')
const nameCellHtml = tbodyHtml.split('<td')[1] ?? ''
expect(nameCellHtml).toContain('São Francisco do Conde com nome extenso')
expect(nameCellHtml).toContain('Metropolitano de Salvador')
expect(nameCellHtml).toContain('href="/campanha/territorios#ti-metropolitano-de-salvador"')
for (const className of [
'hidden @min-[54rem]/municipality-list:table-cell',
'hidden @min-[66rem]/municipality-list:table-cell',
'hidden @min-[72rem]/municipality-list:table-cell',
'hidden @min-[78rem]/municipality-list:table-cell',
]) {
expect(html.split(className)).toHaveLength(3)
}
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:hidden')
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:inline-flex')
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:hidden')
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:block')
const firstRowCells = tbodyHtml.split('<td').slice(1)
expect(firstRowCells[3]?.split('</td>')[0]).toContain('>—</span>')
expect(html).toContain('Sem nível')
})
it.each([
{ hidden: [], hasActions: false },
{ hidden: ['trend'], hasActions: true },
{ hidden: ['lastSignal'], hasActions: true },
{ hidden: ['trend', 'lastSignal'], hasActions: true },
])('mounts one trend and signal editor in the table for $hidden', ({ hidden, hasActions }) => {
const html = renderResponsiveList(hidden)
const tableHtml = html.slice(html.indexOf('<table'), html.indexOf('</table>'))
const theadHtml = tableHtml.slice(tableHtml.indexOf('<thead'), tableHtml.indexOf('</thead>'))
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(1)
expect(tableHtml.match(/aria-label="Registrar sinal em/g)).toHaveLength(1)
expect(theadHtml.includes('Ações')).toBe(hasActions)
})
it('keeps removed and internal columns out of the municipality picker', () => {
const columns = municipalityListPickerColumns({
isStaffView: true,
isCampaignUnrestricted: true,
})
expect(columns.map((column) => column.id)).toEqual([
'name',
'votos',
'expectedVotes',
'level',
'classe',
'advisors',
'trend',
'leaderships',
'stateDeputies',
'goalCoverage',
'lastSignal',
])
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'region' }))
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'actions' }))
})
})
/**
@ -695,10 +837,10 @@ describe('campaign visual foundation', () => {
})
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
// The staff `<thead>` already carries 11 header-explanation tooltips
// The staff `<thead>` already carries 10 header-explanation tooltips
// (B22), regardless of row data. This baseline isolates the advisors
// CELL tooltip, the one this test actually exercises.
const HEADER_TOOLTIP_COUNT = 11
const HEADER_TOOLTIP_COUNT = 10
const baseMunicipality = {
id: 1,
name: 'Seabra',