mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
292df15b3f
commit
fb4ba831c8
13 changed files with 241 additions and 57 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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]}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue