B22: explicacao por coluna no header das listas — description em CampaignTableHead/MunicipalitySortableHead, campaignConceptOneLiner como fonte unica E18, municipalityColumnDescriptions cobrindo as 11 colunas de /campanha/municipios

/simplify: extrai campaignHoverExplanationClassName/campaignHoverTooltipAlign para CampaignHoverTooltip.tsx (elimina duplicacao entre CampaignTableHead, MunicipalitySortableHead e MunicipalityGoalAccountCard), troca non-null assertion de campaignConceptOneLiner por lookup em Record, corrige copy da coluna Assessores (coordenador, nao candidato) e restaura comentario de invariante do sort de goalCoverage
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 02:58:54 -03:00
parent 292df15b3f
commit fb4ba831c8
13 changed files with 241 additions and 57 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).

View file

@ -1,12 +1,20 @@
# Explicação por coluna no header das listas
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue — 2026-07-26
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B22**)
Impeccable: B — encaixe no header já existente da lista de municípios (`MunicipalitySortableHead`) + capacidade promovida para o sistema de listas compartilhado
Appetite: ~0,5 dia eng; promover 1 componente de domínio para `shared/`, 1 prop em 2 heads, copy das colunas de `/campanha/municipios`; sem migration
Impeccable: B — encaixe no header já existente da lista de municípios (`MunicipalitySortableHead`) + prop `description` em `CampaignTableHead`
Appetite: ~0,5 dia eng; 1 prop em 2 heads (`CampaignTableHead`, `MunicipalitySortableHead`), 1 mapa de copy em `municipalityLabels.ts`, 1 helper em `campaignIntelligenceConcepts.ts`; sem migration
Responsável: —
## Revisão 2026-07-26 (auditoria pré-implementação)
O primitivo de tooltip **já foi promovido para `shared/` pelo E10** (entregue 2026-07-25, mesmo dia em que este plano foi escrito, mas depois dele): `MunicipalityHoverTooltip` não existe mais — é `src/components/campaign/shared/CampaignHoverTooltip.tsx`, e o seam genérico `cellTooltip`/`CampaignCellTooltip` também já existe em `CampaignTable`. O Objetivo #3 e a decisão de "mover o primitivo" abaixo estão **concluídos** — nada a fazer aqui. `CampaignSortableHead` (chrome de sort genérico, `wrapSortControl`) também já foi extraído para `shared/` (pela B21), e `MunicipalitySortableHead` já compõe esse primitivo. A contagem de colunas também mudou: o E10 acrescentou a coluna **"Classe"**, que já nasceu com tooltip de header (`CLASS_COLUMN_HINT`) — hoje são **10 colunas no papel staff** (não 9) e **4 delas já têm tooltip de header** (`votos`, `classe`, `frescor`/"Último sinal", `deficit`/"Cobertura da meta"), não 3. O que falta é exatamente o que o objetivo já previa, só que menor: `description` em `CampaignTableHead` (nunca implementado), rename `tooltip`→`description` em `MunicipalitySortableHead` + affordance de sublinhado pontilhado, e o mapa único `municipalityColumnDescriptions` cobrindo as 7 colunas ainda mudas (`name`, `region`, `kind`, `advisors`, `trend`, `expectedVotes`, `lastUpdateAt`). Ver plano de implementação na skill para a lista exata de arquivos.
## Entrega 2026-07-26
Implementado como desenhado na revisão acima: `campaignConceptOneLiner` (`src/lib/campaignIntelligenceConcepts.ts`); `description?: ReactNode` em `CampaignTableHead`; `tooltip` → `description` em `MunicipalitySortableHead` + sublinhado pontilhado no label em ambos; `MunicipalityListColumnId` + `municipalityColumnDescriptions` em `src/utilities/municipalityLabels.ts` (as 11 colunas, `classe` mantendo o texto mais informativo em vez do `oneLiner` terso — decisão assumida documentada na revisão); as 11 chamadas de `MunicipalitySortableHead` em `MunicipalityList.tsx` passam `description=`, e os três `*Hint` locais + a intersecção de tipo extra em `municipalityListColumns()` foram removidos. Testes: pin do `campaignConceptOneLiner` no spec do glossário, `describe('CampaignTableHead description')` em `campaignTable.unit.spec.ts`, e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate completo (`tsc`, `lint`, `format:check`, `check:cycles`, `test:unit` 442/442, `test:int` 387/387, `build`) verde; Aikido sem findings. `knip` segue com o erro pré-existente de carregar `payload.config.ts` (ledgerado P3 no AGENTS.md, confirmado idêntico fora desta mudança).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; anti-goal spreadsheet/data-grid) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells existentes: `CampaignTable` / `CampaignTableHead` (`src/components/campaign/shared/CampaignTable.tsx`), `MunicipalitySortableHead`, `MunicipalityHoverTooltip`, `CampaignInfoHint`.
@ -30,9 +38,9 @@ Brief compacto:
## Contexto
`/campanha/municipios` é a superfície mais densa do produto: tabela de 435 municípios com até 9 colunas no papel staff (Município, Território, Tipo, 2022, Assessores, Tendência, Votos estimados, Último sinal, Cobertura da meta), header já carregado de sort (**B15**) e funil de filtro (**B16**). Várias colunas são métricas derivadas do E8/E9 cujo nome curto não se explica sozinho.
`/campanha/municipios` é a superfície mais densa do produto: tabela de 435 municípios com **10** colunas no papel staff (Município, Território, Tipo, 2022, Classe, Assessores, Tendência, Votos estimados, Último sinal, Cobertura da meta — a coluna "Classe" entrou com o **E10**, 2026-07-25), header já carregado de sort (**B15**) e funil de filtro (**B16**). Várias colunas são métricas derivadas do E8/E9/E10 cujo nome curto não se explica sozinho.
A capacidade **existe pela metade**: `MunicipalitySortableHead` (`src/components/campaign/municipality/MunicipalitySortableHead.tsx`) já aceita `tooltip?: ReactNode` e o envolve em `MunicipalityHoverTooltip` — mas só **3** colunas passam a prop (`votos`, `frescor`, `deficit`, com as frases montadas ad hoc em `MunicipalityList.tsx:410–415`). As outras seis ficam mudas, e as demais listas do produto (lideranças, organizações, dobradinhas, demandas, apoiadores e a futura **B21** territórios) usam `CampaignTableHead`, que não tem prop nenhuma de explicação. O primitivo de tooltip (hover + foco + tap em touch, com dismiss por `pointerdown`) também mora no domínio `municipality`, apesar de não ter nada de municipal.
A capacidade **existe pela metade**: `MunicipalitySortableHead` (`src/components/campaign/municipality/MunicipalitySortableHead.tsx`) já aceita `tooltip?: ReactNode` e o envolve em `CampaignHoverTooltip` (`src/components/campaign/shared/CampaignHoverTooltip.tsx` — **já promovido para `shared/` pelo E10**, não mais `MunicipalityHoverTooltip`) — mas só **4** colunas passam a prop (`votos`, `classe`, `frescor`, `deficit`, com as frases montadas ad hoc em `MunicipalityList.tsx`). As outras seis ficam mudas, e as demais listas do produto (lideranças, organizações, dobradinhas, demandas, apoiadores e a já entregue **B21** territórios) usam `CampaignTableHead`, que não tem prop nenhuma de explicação.
Pedido de produto (2026-07-25): tooltip no hover de cada header de coluna em `/campanha/municipios`, **implementado no componente de header** para ser reaproveitado em outras páginas. Isso também dá evidência datada para o débito **O3** ("jargão") / glossário inline do **R6** — que estava em _A validar_ por falta de pedido concreto.
@ -72,7 +80,7 @@ flowchart LR
Componentes:
- **`CampaignHoverTooltip`** (move de `src/components/campaign/municipality/MunicipalityHoverTooltip.tsx` para `src/components/campaign/shared/CampaignHoverTooltip.tsx`): mesmo comportamento, mesma assinatura; atualizar os imports de `MunicipalityBaselineCard.tsx` e `MunicipalityGoalAccountCard.tsx` e o comentário de cabeçalho (deixa de citar "município").
- **`CampaignHoverTooltip`** — **já em `src/components/campaign/shared/CampaignHoverTooltip.tsx`** (promovido pelo E10, 2026-07-25); nada a mover aqui.
- **`CampaignTableHead`** (`src/components/campaign/shared/CampaignTable.tsx`): nova prop `description?: ReactNode`. Quando presente, envolve o label num `<span tabIndex={0}>` com `focus-visible:ring` e `underline decoration-dotted underline-offset-4` dentro do `CampaignHoverTooltip` — o `<th>` em si continua não interativo. Sem `description`, o render é byte a byte o de hoje.
- **`MunicipalitySortableHead`** (`src/components/campaign/municipality/…`): `tooltip` → `description`, apontando para o primitivo compartilhado; o gatilho continua sendo o próprio `CampaignTransitionAnchor` de sort (já focável, então o teclado já funciona) e ganha o mesmo sublinhado pontilhado.
- **`municipalityColumnDescriptions`** (novo, em `src/utilities/municipalityLabels.ts` — onde os labels da lista já moram): mapa `columnId → string` em pt-BR com as 9 colunas do staff + a coluna `lastUpdateAt` do papel assessor. `goalCoverage` lê `campaignConceptOneLiner('cobertura-da-meta')` e acrescenta a nota de cenário que hoje está no `deficitHint`; `frescor` reaproveita a frase atual com `MUNICIPALITY_COLD_SIGNAL_DAYS`; `votos` reaproveita `formatMunicipalityConcentrationHint()`.
@ -103,6 +111,7 @@ Componentes:
- **Copy de coluna nas listas de lideranças/organizações/dobradinhas/demandas/apoiadores.** Revisitar quando: alguém do time perguntar o que uma coluna dessas significa em sessão/R6, ou quando a superfície ganhar coluna derivada.
- **Variante Popover (com link) no mobile.** Revisitar quando: o critique do R6 registrar falha de descoberta em touch — o tap já abre o tooltip hoje.
- **`description` só foi ligado a `MunicipalitySortableHead`, não à base compartilhada `CampaignSortableHead`.** Achado do `/simplify` da entrega (2026-07-26): hoje é 1 call site (municípios), então subir a prop para o componente genérico seria abstração sem 2º uso. Revisitar quando: um 2º domínio com header rico (dobradinhas **B33**, territórios **B21**) pedir explicação de coluna — aí `description`/`wrapSortControl`+sublinhado pontilhado sobe para `CampaignSortableHead` e os dois wrappers de domínio ficam finos.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,10 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint'
import { Badge } from '@/components/ui/Badge'
import { Progress } from '@/components/ui/Progress'
@ -12,6 +15,7 @@ import {
type CampaignConceptId,
} from '@/lib/campaignIntelligenceConcepts'
import { formatElectionNumber } from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
formatGoalCoverageDeficitLabel,
@ -63,7 +67,7 @@ const GoalAccountMetric = ({
aria-label={`${label}: mais informações`}
className="flex min-h-11 w-fit flex-col items-start gap-0.5 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<dt className="text-xs text-muted-foreground underline decoration-dotted decoration-muted-foreground/70 underline-offset-2">
<dt className={cn('text-xs text-muted-foreground', campaignHoverExplanationClassName)}>
{label}
</dt>
<dd className="tabular-nums">{value}</dd>
@ -145,7 +149,7 @@ const TerritorialClassRow = ({
aria-label={`Classe: ${territorialClassLabels[territorialClass.class]}. Mais informações`}
className="flex min-h-11 w-fit items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="text-xs text-muted-foreground underline decoration-dotted decoration-muted-foreground/70 underline-offset-2">
<span className={cn('text-xs text-muted-foreground', campaignHoverExplanationClassName)}>
Classe
</span>
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>

View file

@ -26,12 +26,11 @@ import {
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
import { formatMunicipalityVoteRank } from '@/lib/municipalityVoteRank'
import { cn } from '@/lib/utils'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalityConcentrationHint,
formatMunicipalityGeographyLabel,
formatTerritorialClassWhy,
municipalityColumnDescriptions,
municipalityKindLabels,
municipalityPriorityLabels,
territorialClassBadgeVariant,
@ -113,9 +112,6 @@ const VotePositionReadout = ({
)
}
const CLASS_COLUMN_HINT =
'Leitura relativa de 2022: o desempenho aqui contra o padrão estadual do próprio candidato. Reduto (bem acima do padrão), Expansão (abaixo, mas com campo a ocupar), Manutenção (no padrão), Marginal (abaixo, com pouco campo) e — sem série do TSE. É sugestão de leitura, não decisão: cada classe traz o porquê ao passar o mouse ou tocar nela.'
/**
* E10 classe. In the table only the pill is visible — spelling the factors out
* under it made this the widest cell in a grid that cannot scroll
@ -256,14 +252,7 @@ const municipalityListColumns = ({
trendFormAction,
advisorsFormAction,
signalFormAction,
concentrationHint,
signalHint,
deficitHint,
}: MunicipalityListProps & {
concentrationHint: string
signalHint: string
deficitHint: string
}): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
}: MunicipalityListProps): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
{
id: 'name',
mandatory: true,
@ -274,6 +263,7 @@ const municipalityListColumns = ({
filterParam="name"
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
description={municipalityColumnDescriptions.name}
>
Município
</MunicipalitySortableHead>
@ -301,6 +291,7 @@ const municipalityListColumns = ({
sortKey="region"
filterParam="region"
filterOptions={columnFilterOptions.region}
description={municipalityColumnDescriptions.region}
>
Território
</MunicipalitySortableHead>
@ -311,7 +302,12 @@ const municipalityListColumns = ({
{
id: 'kind',
head: (
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
<MunicipalitySortableHead
state={state}
sortKey="kind"
filterParam="kind"
description={municipalityColumnDescriptions.kind}
>
Tipo
</MunicipalitySortableHead>
),
@ -324,7 +320,7 @@ const municipalityListColumns = ({
state={state}
sortKey="votos"
align="right"
tooltip={concentrationHint}
description={municipalityColumnDescriptions.votos}
/>
),
cellClassName: 'text-right',
@ -346,7 +342,7 @@ const municipalityListColumns = ({
state={state}
sortKey="classe"
filterParam="class"
tooltip={CLASS_COLUMN_HINT}
description={municipalityColumnDescriptions.classe}
>
Classe
</MunicipalitySortableHead>
@ -369,6 +365,7 @@ const municipalityListColumns = ({
sortKey="coverage"
filterParam="advisor"
filterOptions={columnFilterOptions.advisor}
description={municipalityColumnDescriptions.advisors}
>
Assessores
</MunicipalitySortableHead>
@ -394,7 +391,12 @@ const municipalityListColumns = ({
{
id: 'trend',
head: (
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
<MunicipalitySortableHead
state={state}
sortKey="trend"
filterParam="trend"
description={municipalityColumnDescriptions.trend}
>
Tendência
</MunicipalitySortableHead>
),
@ -411,7 +413,12 @@ const municipalityListColumns = ({
{
id: 'expectedVotes',
head: (
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
<MunicipalitySortableHead
state={state}
sortKey="expectedVotes"
align="center"
description={municipalityColumnDescriptions.expectedVotes}
>
Votos estimados
</MunicipalitySortableHead>
),
@ -429,7 +436,11 @@ const municipalityListColumns = ({
{
id: 'lastSignal',
head: (
<MunicipalitySortableHead state={state} sortKey="frescor" tooltip={signalHint}>
<MunicipalitySortableHead
state={state}
sortKey="frescor"
description={municipalityColumnDescriptions.lastSignal}
>
Último sinal
</MunicipalitySortableHead>
),
@ -449,7 +460,11 @@ const municipalityListColumns = ({
{
id: 'goalCoverage',
head: (
<MunicipalitySortableHead state={state} sortKey="deficit" tooltip={deficitHint}>
<MunicipalitySortableHead
state={state}
sortKey="deficit"
description={municipalityColumnDescriptions.goalCoverage}
>
Cobertura da meta
</MunicipalitySortableHead>
),
@ -465,7 +480,11 @@ const municipalityListColumns = ({
{
id: 'lastUpdateAt',
head: (
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
<MunicipalitySortableHead
state={state}
sortKey="lastUpdateAt"
description={municipalityColumnDescriptions.lastUpdateAt}
>
Última atualização
</MunicipalitySortableHead>
),
@ -489,17 +508,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
} = props
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const concentrationHint = formatMunicipalityConcentrationHint()
const signalHint = `Ú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 is client state, so the server can only order by one
// scenario — named here so the ordering never looks arbitrary.
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
const columns = municipalityListColumns({
...props,
concentrationHint,
signalHint,
deficitHint,
})
const columns = municipalityListColumns(props)
return (
<>
@ -619,7 +628,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
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}. Coluna 2022: {concentrationHint}
{sortSummary}. Coluna 2022: {municipalityColumnDescriptions.votos}
</>
}
columns={columns}

View file

@ -3,7 +3,11 @@
import type { ReactNode } from 'react'
import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import type {
MunicipalityFilterOption,
@ -22,7 +26,8 @@ type MunicipalitySortableHeadProps = {
state: MunicipalityListState
sortKey: MunicipalityListSortKey
children?: ReactNode
tooltip?: ReactNode
/** Hover/focus/tap explanation for the column (B22) — names the content, not the mechanism. */
description?: ReactNode
className?: string
align?: 'left' | 'center' | 'right'
filterParam?: MunicipalityFilterParam
@ -35,7 +40,7 @@ export const MunicipalitySortableHead = ({
state,
sortKey,
children,
tooltip,
description,
className,
align = 'left',
filterParam,
@ -71,19 +76,16 @@ export const MunicipalitySortableHead = ({
) : null
}
wrapSortControl={
tooltip
description
? (control) => (
<CampaignHoverTooltip
content={tooltip}
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
>
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
{control}
</CampaignHoverTooltip>
)
: undefined
}
>
{label}
{description ? <span className={campaignHoverExplanationClassName}>{label}</span> : label}
</CampaignSortableHead>
)
}

View file

@ -31,6 +31,20 @@ type TappableElement = ReactElement<{
ref?: Ref<HTMLElement>
}>
/**
* Dotted-underline affordance for any label wrapped in a `CampaignHoverTooltip`
* (column headers, card metrics). Shared so the four call sites don't drift
* on the exact underline styling.
*/
export const campaignHoverExplanationClassName =
'underline decoration-dotted decoration-muted-foreground/70 underline-offset-2'
/** Maps a left/center/right layout align to the tooltip `align` it should open toward. */
export const campaignHoverTooltipAlign = (
align: 'left' | 'center' | 'right',
): 'start' | 'center' | 'end' =>
align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'
export const CampaignHoverTooltip = ({
content,
children,

View file

@ -1,6 +1,11 @@
import { Fragment, type ReactNode } from 'react'
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import {
Table,
TableBody,
@ -46,15 +51,25 @@ export type CampaignTableColumn<Row> = {
defaultVisible?: boolean
}
/** Plain (non-sortable) header cell for column definitions. */
/**
* Plain (non-sortable) header cell for column definitions. `description`
* (B22) wraps the label in the same `CampaignHoverTooltip` `cellTooltip` uses
* for cells, but — unlike `cellTooltip`, which adds no tab stop because it
* repeats content already on the row — a header's explanation is often new
* information, so the wrapping `<span tabIndex={0}>` is a deliberate new tab
* stop; the `<th>` itself stays non-interactive. Without `description` the
* render is byte-for-byte what it was before B22.
*/
export const CampaignTableHead = ({
align = 'left',
className,
children,
description,
}: {
align?: 'left' | 'center' | 'right'
className?: string
children?: ReactNode
description?: ReactNode
}) => (
<TableHead
className={cn(
@ -64,7 +79,21 @@ export const CampaignTableHead = ({
className,
)}
>
{children}
{description ? (
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
<span
tabIndex={0}
className={cn(
campaignHoverExplanationClassName,
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{children}
</span>
</CampaignHoverTooltip>
) : (
children
)}
</TableHead>
)

View file

@ -187,3 +187,18 @@ export const CAMPAIGN_CONCEPTS_PATH = '/campanha/conceitos'
/** Anchor href for a documented concept — typed so a renamed id can't leave a dead link behind. */
export const campaignConceptHref = (id: CampaignConceptId): string =>
`${CAMPAIGN_CONCEPTS_PATH}#${id}`
/**
* `oneLiner` indexed by id, built once at module init. Callers (e.g. the B22
* column-header tooltips) quote the same sentence the glossary page shows
* instead of writing a second, driftable one. The cast is the single place
* that assumes every `CampaignConceptId` has a matching concept — the
* "unique ids" test above pins that assumption for the whole union, so
* `campaignConceptOneLiner` itself stays a plain, assertion-free lookup.
*/
const campaignConceptOneLinerByID = Object.fromEntries(
campaignIntelligenceConcepts.map((concept) => [concept.id, concept.oneLiner]),
) as Record<CampaignConceptId, string>
export const campaignConceptOneLiner = (id: CampaignConceptId): string =>
campaignConceptOneLinerByID[id]

View file

@ -5,14 +5,17 @@
* from a `server-only` module (split out of the former `municipalityUi.ts` in
* Pass 2 W1).
*/
import { campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts'
import {
formatElectionNumber,
formatVoteSharePercent,
oneDecimalFormatter,
} from '@/lib/electionFormat'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage'
import { MUNICIPALITY_COLD_SIGNAL_DAYS } from '@/utilities/municipalitySignal'
import type {
MunicipalityTerritorialClass,
TerritorialFactor,
@ -148,3 +151,54 @@ export const formatMunicipalityGeographyLabel = (municipality: {
municipality.kind === 'zona' && municipality.zoneNumber != null
? `${municipality.region} · ZE ${municipality.zoneNumber}`
: municipality.region
/**
* The 11 real column ids in `/campanha/municipios` — 10 staff columns plus
* `lastUpdateAt`, which only the non-staff (leader) view renders. Closed union
* so `municipalityColumnDescriptions` below can't silently drop a column.
*/
export type MunicipalityListColumnId =
| 'name'
| 'region'
| 'kind'
| 'votos'
| 'classe'
| 'advisors'
| 'trend'
| 'expectedVotes'
| 'lastSignal'
| 'goalCoverage'
| 'lastUpdateAt'
/**
* E10 "Classe" header hint — kept verbatim rather than swapped for the
* terser E18 `oneLiner` (`classe-territorial`): a 5-way classification needs
* more than one sentence, and the shorter concept text would be a clarity
* downgrade for this specific column.
*/
const CLASS_COLUMN_DESCRIPTION =
'Leitura relativa de 2022: o desempenho aqui contra o padrão estadual do próprio candidato. Reduto (bem acima do padrão), Expansão (abaixo, mas com campo a ocupar), Manutenção (no padrão), Marginal (abaixo, com pouco campo) e — sem série do TSE. É sugestão de leitura, não decisão: cada classe traz o porquê ao passar o mouse ou tocar nela.'
/**
* B22 — single source of copy for every `/campanha/municipios` column header
* tooltip. E18-documented metrics quote their canonical `oneLiner` instead of
* a second, driftable sentence; the rest is new short pt-BR copy. Computed at
* module scope: every entry here only ever depended on static constants, not
* 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.',
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
votos: formatMunicipalityConcentrationHint(),
classe: CLASS_COLUMN_DESCRIPTION,
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),
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
// arbitrary next to whatever scenario is currently selected.
goalCoverage: `${campaignConceptOneLiner('cobertura-da-meta')} Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`,
lastUpdateAt: 'Data do último registro da equipe ou de liderança neste município.',
}

View file

@ -240,6 +240,11 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Cobertura da meta')
expect(html).toContain('80%')
expect(html).toContain('Faltam 300 votos para a meta')
// B22: every one of the 10 staff columns carries a header explanation —
// pinned in the `<thead>` only, since `cellTooltip` (e.g. "Classe") adds
// its own triggers in the body.
const [theadHtml] = html.split('<tbody')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
})
/**
@ -471,5 +476,9 @@ describe('campaign visual foundation', () => {
expect(html).not.toContain('Votos estimados')
expect(html).not.toContain('Cobertura')
expect(html).not.toContain('Prioritária')
// B22: the leader view's 5 columns (name/region/kind/votos/lastUpdateAt)
// also carry header explanations.
const [theadHtml] = html.split('<tbody')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(5)
})
})

View file

@ -11,6 +11,7 @@ import {
CAMPAIGN_CONCEPT_CATEGORIES,
CAMPAIGN_CONCEPTS_PATH,
campaignConceptHref,
campaignConceptOneLiner,
campaignConceptsByCategory,
campaignIntelligenceConcepts,
} from '@/lib/campaignIntelligenceConcepts'
@ -54,6 +55,12 @@ describe('campaignIntelligenceConcepts', () => {
it('builds anchor hrefs into the concepts page', () => {
expect(campaignConceptHref('cobertura-da-meta')).toBe('/campanha/conceitos#cobertura-da-meta')
})
it('quotes the same oneLiner the glossary page renders (B22 single source)', () => {
const meta = campaignIntelligenceConcepts.find((concept) => concept.id === 'meta')
expect(meta).toBeDefined()
expect(campaignConceptOneLiner('meta')).toBe(meta!.oneLiner)
})
})
/**

View file

@ -61,3 +61,34 @@ describe('CampaignTable cellTooltip', () => {
expect(renderTable((row) => row.note)).not.toContain('Vereador migrou para a base')
})
})
/**
* B22 seam: a plain header can carry the same redundant hover/focus/tap
* explanation `cellTooltip` gives a cell. Without `description` the render is
* byte-for-byte what it was before B22 — no new wrapper, no new tab stop.
*/
describe('CampaignTableHead description', () => {
it('renders unchanged when no description is declared', () => {
const html = renderToStaticMarkup(createElement(CampaignTableHead, {}, 'Território'))
expect(html).toContain('Território')
expect(html).not.toContain('data-slot="tooltip-trigger"')
expect(html).not.toContain('tabindex')
})
it('wraps the label in exactly one tooltip trigger when description is declared', () => {
const html = renderToStaticMarkup(
createElement(
CampaignTableHead,
{ description: 'Território de Identidade a que o município pertence.' },
'Território',
),
)
expect(html.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
expect(html).toContain('>Território<')
// Same contract as cellTooltip: Radix only mounts content once opened, so
// the description text never reaches the server markup.
expect(html).not.toContain('Território de Identidade a que o município pertence.')
})
})