From fb4ba831c88bb9c8a50758c712aac8b6f79ea732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 02:58:54 -0300 Subject: [PATCH] =?UTF-8?q?B22:=20explicacao=20por=20coluna=20no=20header?= =?UTF-8?q?=20das=20listas=20=E2=80=94=20description=20em=20CampaignTableH?= =?UTF-8?q?ead/MunicipalitySortableHead,=20campaignConceptOneLiner=20como?= =?UTF-8?q?=20fonte=20unica=20E18,=20municipalityColumnDescriptions=20cobr?= =?UTF-8?q?indo=20as=2011=20colunas=20de=20/campanha/municipios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + .../plans/explicacao-colunas-header-listas.md | 23 ++++-- docs/roadmap.md | 6 +- .../MunicipalityGoalAccountCard.tsx | 10 ++- .../municipality/MunicipalityList.tsx | 75 +++++++++++-------- .../municipality/MunicipalitySortableHead.tsx | 20 ++--- .../campaign/shared/CampaignHoverTooltip.tsx | 14 ++++ .../campaign/shared/CampaignTable.tsx | 33 +++++++- src/lib/campaignIntelligenceConcepts.ts | 15 ++++ src/utilities/municipalityLabels.ts | 54 +++++++++++++ tests/unit/campaignComponents.unit.spec.ts | 9 +++ .../campaignIntelligenceConcepts.unit.spec.ts | 7 ++ tests/unit/campaignTable.unit.spec.ts | 31 ++++++++ 13 files changed, 241 insertions(+), 57 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a214e173..ae0561cb 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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 `` (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). diff --git a/docs/plans/explicacao-colunas-header-listas.md b/docs/plans/explicacao-colunas-header-listas.md index aea72586..4eb4872d 100644 --- a/docs/plans/explicacao-colunas-header-listas.md +++ b/docs/plans/explicacao-colunas-header-listas.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 `` (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 `` com `focus-visible:ring` e `underline decoration-dotted underline-offset-4` dentro do `CampaignHoverTooltip` — o `` 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index e320146c..b513d220 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-25 (**E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -96,7 +96,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios. - ~~**B15** ordenar lista de municípios pelo header da coluna~~ — **entregue 2026-07-24** (`?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado; **A11** consome o contrato (key `votos`)) · [plano](plans/ordenacao-colunas-lista-municipios.md) - ~~**B16** filtros no header das colunas da lista de municípios~~ — **entregue 2026-07-24** (funil+Popover no `TableHead` ao lado do sort B15; barra slim busca+resumo+Limpar; mobile selects empilhados; optimistic + busca no Popover de TI) · [plano](plans/filtros-no-header-lista-municipios.md) -- **B22** explicação por coluna no header das listas — pedido de produto (2026-07-25): tooltip no hover de cada header de `/campanha/municipios` dizendo o que a coluna mede, **implementado no componente de header** para reuso. Hoje a capacidade existe pela metade (prop `tooltip` do `MunicipalitySortableHead`, usada em 3 de 9 colunas; primitivo preso ao domínio `municipality`; `CampaignTableHead` sem prop nenhuma): promover `MunicipalityHoverTooltip`→`shared/CampaignHoverTooltip`, prop `description` nos dois heads, copy das colunas de municípios com `oneLiner` do **E18 ✓** como fonte única nas métricas do E8; sem migration/action; dá evidência datada para o débito **O3**/glossário inline do R6 · ~0,5d · Janela 1–2 · Impeccable B · soft: B15 ✓/B16 ✓/E18 ✓ · cortável · [plano](plans/explicacao-colunas-header-listas.md) +- ~~**B22** explicação por coluna no header das listas~~ — **entregue 2026-07-26** (`description?: ReactNode` em `CampaignTableHead` e `MunicipalitySortableHead` — rename de `tooltip`, sublinhado pontilhado no label, mesmo `CampaignHoverTooltip` compartilhado já promovido pelo **E10 ✓**; `campaignConceptOneLiner` em `campaignIntelligenceConcepts.ts` como fonte única para as colunas cobertas pelo **E18 ✓**; `municipalityColumnDescriptions` cobre as 11 colunas de `/campanha/municipios`, staff e leader; sem migration/action) · [plano](plans/explicacao-colunas-header-listas.md) - **B23** tooltip de conteúdo nas células das listas — **a mecânica genérica já está no repo** (entregue dentro do **E10 ✓** em 2026-07-25, a pedido do produto: a célula "Classe" ficava larga demais com o "por quê" por extenso): `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` em `shared/CampaignCellTooltip`, e `MunicipalityHoverTooltip` **já promovido** para `shared/CampaignHoverTooltip` (3 call sites migrados; B22 só consome). Contrato de acessibilidade pinado em `tests/unit/campaignTable.unit.spec.ts`: coluna sem `cellTooltip` renderiza sem wrapper, linha com tooltip vazia idem, e o conteúdo do Radix **não** existe no markup do servidor — por isso a informação tem de estar na célula como texto (`sr-only` atrás do badge, como a Classe faz). **O que resta do B23** são os dois consumidores originais e a convivência com o Popover (`openOnTouch` / `disabled`, que ainda não existem no primitivo) — dois pedidos de produto (2026-07-25): (a) hover na célula de "Assessores" em `/campanha/municipios` mostra os nomes dos assessores do município (hoje só há até 3 avatares de **iniciais** + `sr-only`; do 4º em diante nem avatar); (b) hover na célula de "Tendência" mostra a **justificativa** registrada (`politicalTrend.note`, semeada em massa pelo **E4R ✓** — hoje já chega na célula, mas só como ``, e só é legível abrindo o detalhe), **sem tooltip quando não há nota**. Ambos **implementados como capacidade genérica da célula** para reuso em outros campos/páginas: `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` no primitivo compartilhado (`CampaignHoverTooltip` — promovido no **B22** ou aqui, o que vier primeiro); sem novos tab stops (tooltip é redundante; texto acessível segue na célula) e, nas células que já são gatilho de Popover de edição rápida (assessores do coordenador, tendência), `openOnTouch={false}` + `disabled={open}` para não roubar o tap nem sobreviver atrás do Popover (B9/B19 ✓) — em touch a informação aparece dentro do próprio Popover (a nota da tendência entra ali como leitura, acima do select); sem migration/action/query nova · ~0,4d (era ~0,75d; a capacidade genérica e a promoção do primitivo saíram no E10 ✓) · Janela 1–2 · Impeccable B · soft: B22 (só consome o primitivo agora), E4R ✓ (as notas) · cortável · [plano](plans/tooltip-celulas-listas.md) - **B24** auto-save + justificativa no popover de Tendência da lista de municípios — pedido de produto (2026-07-25): o popover da coluna "Tendência" perde o botão **"Salvar"** (grava sozinho: status na hora, justificativa com debounce que **cancela o timer anterior**, flush no blur/fechamento) e ganha o campo para **editar a justificativa** (`politicalTrend.note`, hoje hidden input na lista e só editável em `/[slug]/editar`). Fecha duas dívidas datadas: o anti-goal literal do princípio 4 do `PRODUCT.md` ("Save buttons on Popovers and quick-edit cells") — a célula vizinha da mesma linha (votos estimados) já grava sozinha — e o "nota de tendência no popover, gatilho: feedback de uso" adiado no **B9 ✓**. Persistência por endpoint JSON próprio (`POST /campanha/municipios/political-trend`, espelho do `expected-votes/`), **sem revalidar** a lista de 435 linhas por gravação; `setMunicipalityPoliticalTrend` e o access ficam intactos; sem migration/collection/Consent · ~0,5d · Janela 1–2 · Impeccable B · soft: B23 (se vier antes, a nota read-only do Popover vira o textarea) · cortável · [plano](plans/autosave-tendencia-lista-municipios.md) - **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md) @@ -316,7 +316,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B22** explicação no header (a página `/campanha/conceitos` do E18 ✓ continua explicando as métricas do E8 — cortar só custa o atalho em contexto, e o onboarding presencial cobre no curto prazo); **B23** tooltip nas células (as iniciais + o Popover de edição do coordenador continuam dando os nomes, e a justificativa da tendência continua no detalhe do município — cortar custa a leitura a olho na varredura; corte junto com B22, já que dividem o primitivo); **B24** auto-save + justificativa na Tendência (o botão "Salvar" continua funcionando e a justificativa continua editável em `/[slug]/editar` — cortar custa coerência com a célula vizinha e um desvio conhecido do princípio 4; é o corte mais barato desta lista, mas também o mais barato de fazer); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); **B33** sort + filtro no header das dobradinhas (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B23** tooltip nas células (as iniciais + o Popover de edição do coordenador continuam dando os nomes, e a justificativa da tendência continua no detalhe do município — cortar custa a leitura a olho na varredura; **B22** ✓ já entregou o primitivo compartilhado, então este corte não afeta o header, só as células); **B24** auto-save + justificativa na Tendência (o botão "Salvar" continua funcionando e a justificativa continua editável em `/[slug]/editar` — cortar custa coerência com a célula vizinha e um desvio conhecido do princípio 4; é o corte mais barato desta lista, mas também o mais barato de fazer); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); **B33** sort + filtro no header das dobradinhas (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais diff --git a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx index 68c93004..f8c225eb 100644 --- a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx @@ -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" > -
+
{label}
{value}
@@ -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" > - + Classe diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 242ad76d..6631a225 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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> => [ +}: MunicipalityListProps): Array> => [ { id: 'name', mandatory: true, @@ -274,6 +263,7 @@ const municipalityListColumns = ({ filterParam="name" filterOptions={columnFilterOptions.name} showPriorityFilter={isStaffView} + description={municipalityColumnDescriptions.name} > Município @@ -301,6 +291,7 @@ const municipalityListColumns = ({ sortKey="region" filterParam="region" filterOptions={columnFilterOptions.region} + description={municipalityColumnDescriptions.region} > Território @@ -311,7 +302,12 @@ const municipalityListColumns = ({ { id: 'kind', head: ( - + Tipo ), @@ -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 @@ -369,6 +365,7 @@ const municipalityListColumns = ({ sortKey="coverage" filterParam="advisor" filterOptions={columnFilterOptions.advisor} + description={municipalityColumnDescriptions.advisors} > Assessores @@ -394,7 +391,12 @@ const municipalityListColumns = ({ { id: 'trend', head: ( - + Tendência ), @@ -411,7 +413,12 @@ const municipalityListColumns = ({ { id: 'expectedVotes', head: ( - + Votos estimados ), @@ -429,7 +436,11 @@ const municipalityListColumns = ({ { id: 'lastSignal', head: ( - + Último sinal ), @@ -449,7 +460,11 @@ const municipalityListColumns = ({ { id: 'goalCoverage', head: ( - + Cobertura da meta ), @@ -465,7 +480,11 @@ const municipalityListColumns = ({ { id: 'lastUpdateAt', head: ( - + Última atualização ), @@ -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} diff --git a/src/components/campaign/municipality/MunicipalitySortableHead.tsx b/src/components/campaign/municipality/MunicipalitySortableHead.tsx index bdf7491e..d86ef921 100644 --- a/src/components/campaign/municipality/MunicipalitySortableHead.tsx +++ b/src/components/campaign/municipality/MunicipalitySortableHead.tsx @@ -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) => ( - + {control} ) : undefined } > - {label} + {description ? {label} : label} ) } diff --git a/src/components/campaign/shared/CampaignHoverTooltip.tsx b/src/components/campaign/shared/CampaignHoverTooltip.tsx index 1d99cdda..ca298b23 100644 --- a/src/components/campaign/shared/CampaignHoverTooltip.tsx +++ b/src/components/campaign/shared/CampaignHoverTooltip.tsx @@ -31,6 +31,20 @@ type TappableElement = ReactElement<{ ref?: Ref }> +/** + * 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, diff --git a/src/components/campaign/shared/CampaignTable.tsx b/src/components/campaign/shared/CampaignTable.tsx index 0679dd1e..fa8f43ea 100644 --- a/src/components/campaign/shared/CampaignTable.tsx +++ b/src/components/campaign/shared/CampaignTable.tsx @@ -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 = { 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 `` is a deliberate new tab + * stop; the `` 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 }) => ( - {children} + {description ? ( + + + {children} + + + ) : ( + children + )} ) diff --git a/src/lib/campaignIntelligenceConcepts.ts b/src/lib/campaignIntelligenceConcepts.ts index 0b1f4142..e6adc29d 100644 --- a/src/lib/campaignIntelligenceConcepts.ts +++ b/src/lib/campaignIntelligenceConcepts.ts @@ -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 + +export const campaignConceptOneLiner = (id: CampaignConceptId): string => + campaignConceptOneLinerByID[id] diff --git a/src/utilities/municipalityLabels.ts b/src/utilities/municipalityLabels.ts index 1230c7a6..981184ac 100644 --- a/src/utilities/municipalityLabels.ts +++ b/src/utilities/municipalityLabels.ts @@ -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 = { + 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.', +} diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 6fb09a23..a7670cb5 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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 `` only, since `cellTooltip` (e.g. "Classe") adds + // its own triggers in the body. + const [theadHtml] = html.split(' { 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(' { 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) + }) }) /** diff --git a/tests/unit/campaignTable.unit.spec.ts b/tests/unit/campaignTable.unit.spec.ts index 94d4ef4b..efa3c039 100644 --- a/tests/unit/campaignTable.unit.spec.ts +++ b/tests/unit/campaignTable.unit.spec.ts @@ -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.') + }) +})