From 4cb59597ffb621087d7d76188a7576a8cc294adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 21:57:53 -0300 Subject: [PATCH] B67: restore fine-pointer drag pan on home action strip B58 hid the scrollbar but dropped mouse drag-to-scroll; pointer-fine handlers pan past an 8px threshold while coarse touch keeps native pan-x. Includes simplify pass (imperative data-dragging, coarse-pointer test) and pin ratchet for municipalityElectoralBaseline bypass count. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- .../plans/restaurar-pan-strip-acoes-inicio.md | 4 +- docs/roadmap.md | 14 +- .../dashboard/CampaignHomeActionStrip.tsx | 154 +++++++++++++++--- .../campaignHomeActionButton.unit.spec.tsx | 111 ++++++++++++- tests/unit/codebaseConventions.unit.spec.ts | 2 +- 6 files changed, 256 insertions(+), 32 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 15605d9b..1eaa6073 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -8,7 +8,8 @@ alwaysApply: false ## Status - **B56 entregue em código (2026-07-29):** resumo da campanha no Início staff — `loadCampaignHomeSummary` + `CampaignHomeSummary` (total central `staffVoteTotal` + cobertura E8 com `Progress`); `summarySlot` em `CampaignHomeLayout`/`CampaignHomeStaffChrome`, oculto no modo focado B47; sem saudação; leader fora. Loader em `campaignDashboardData.ts` (não módulo top-level novo). Unit `campaignHomeSummary.unit.spec.tsx`. Sem migration. Plano: [`resumo-campanha-inicio.md`](../../../docs/plans/resumo-campanha-inicio.md). Desbloqueia slot visual de **B57** (delta 7d, futuro). -- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta com pan preservado; escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). Próximo chassis: **B48** resultados busca. +- **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44. +- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). - **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md). - **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças (mesmo payload); **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md). - **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~ diff --git a/docs/plans/restaurar-pan-strip-acoes-inicio.md b/docs/plans/restaurar-pan-strip-acoes-inicio.md index 5821c36a..be834c27 100644 --- a/docs/plans/restaurar-pan-strip-acoes-inicio.md +++ b/docs/plans/restaurar-pan-strip-acoes-inicio.md @@ -1,7 +1,7 @@ # Restaurar pan/scroll na strip de ações (scrollbar oculta) -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: `pointer-fine` drag-to-scroll em `CampaignHomeActionStrip` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8); scrollbar oculta mantida; `onClickCapture` suprime navegação após arraste; touch `pan-x` nativo inalterado; fecha Adiado drag desktop de B44. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B67** — UX-1 chassis) Impeccable: B — encaixe em `CampaignHomeActionStrip` / `CampaignHomeActionButton` Appetite: ~0,25–0,5 dia eng; overflow + drag-to-scroll; sem migration diff --git a/docs/roadmap.md b/docs/roadmap.md index 666216ad..acceb79e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; **B67** restaurar pan na strip; **B68** sugestões na busca aberta; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B63. +Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; ~~**B67**~~ ✓ pan na strip; **B68** sugestões na busca aberta; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B63. 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) @@ -83,12 +83,12 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → **B67** (pan strip — regressão pós-B58) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** **B59** → **B60**/**B61**/**B64** · **B62** → **B63**. +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** **B59** → **B60**/**B61**/**B64** · **B62** → **B63**. - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) - **B58** Polimento visual da strip de ações do Início — ~~remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado)~~ — **entregue 2026-07-29** (catálogo + strip + botão; e2e atualizado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md) · **regressão de pan → B67** -- **B67** Restaurar pan/scroll na strip de ações (scrollbar continua oculta; wheel + toque-e-puxar + **click+arrasta** no desktop — fecha Adiado B44) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B65/B66/B59 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md) +- **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md) - **B59** Chassis do wizard (shell de passo + seta Voltar só mobile; desktop = browser; **município sticky** no limite superior central, semi-discreto, após escolha) · Depende de B45 ✓ · ~0,75–1d · Janela 1 · Impeccable C · [plano](plans/chassis-wizard-campanha.md) - **B60** Etapa busca município nos wizards (UX da busca do Início, só municípios; select → avança) · Depende de B59 · soft B48 · ~1d · Janela 1 · Impeccable B · [plano](plans/busca-municipio-wizard.md) - **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md) @@ -214,7 +214,7 @@ flowchart TD B44n["B44 ✓ Botão ação
+ strip horizontal"] B45n["B45 ✓ Catálogo ações
por persona"] B58n["B58 ✓ Polish strip
ações Início"] - B67n["B67 Restaurar pan
strip ações"] + B67n["B67 ✓ Restaurar pan
strip ações"] B46n["B46 ✓ Thumb zone
ações Início"] B47n["B47 ✓ Busca global
input + modo focado"] B66n["B66 Focus limpa tela
+ animação busca"] @@ -438,11 +438,11 @@ flowchart TD B8F2 -.rank por ZE = débito aberto.-> B14n ``` -Paralelizáveis agora: **B67** (pan strip — regressão; quick win); **B65** (âncora inferior mobile); **B66** (focus limpa busca — melhor após B65); **B68** (sugestões empty — após B48 ✓; melhor após B66); **B59** (chassis wizard — desbloqueia B60/B61/B63/B64); **B62** (simplificar sinal — paralelo a B59); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B59**: **B60** ∥ **B61** ∥ **B64**. **B63** espera B59+B62. Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. +Paralelizáveis agora: **B65** (âncora inferior mobile); **B66** (focus limpa busca — melhor após B65); **B68** (sugestões empty — após B48 ✓; melhor após B66); **B59** (chassis wizard — desbloqueia B60/B61/B63/B64); **B62** (simplificar sinal — paralelo a B59); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B59**: **B60** ∥ **B61** ∥ **B64**. **B63** espera B59+B62. Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → B67** (pan strip) **→ B65** (âncora inferior) **→ B66** (focus limpa busca) **→ B68** (sugestões empty) **→** **wizards B59** → B60∥B61∥B64 · B62 → B63 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = pan B67 + polish busca B65/B66/B68 + busca B49+ + wizards B59–B64. **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; ~~**B38**~~ ✓ sidebar recolhido em tablet (entregue 2026-07-26 — `offcanvas` + trigger `md+` + cookie/`matchMedia`); **B39** (lembrar de mim / sessão longa — o default de 2 h está derrubando sessão no onboarding; ~0,5 dia, entra cedo) e, logo atrás se folga, ~~**B40**~~ ✓ (biometria WebAuthn — entregue 2026-07-28); ~~**B41**~~ ✓ (scroll horizontal + sticky na lista de municípios — entregue 2026-07-27) e ~~**B42**~~ ✓ (polimento mobile da mesma lista — Drawer em Tendência/Assessores/Votos estimados + card compacto e clicável; entregue 2026-07-27); **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); ~~**B32**~~ ✓ (editar Status de apoio na célula da mesma lista — entregue 2026-07-26; fecha o adiado do próprio B29); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); ~~**B17**~~ ✓ (seletor de colunas) / ~~**B18**~~ ✓ (filtros salvos + atalho sidebar) entregues 2026-07-28; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14 ✓** (atalho geo entregue 2026-07-26); Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ B65** (âncora inferior) **→ B66** (focus limpa busca) **→ B68** (sugestões empty) **→\*\* **wizards B59** → B60∥B61∥B64 · B62 → B63 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = polish busca B65/B66/B68 + busca B49+ + wizards B59–B64. **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -452,7 +452,7 @@ Paralelizáveis agora: **B67** (pan strip — regressão; quick win); **B65** ( ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; **B67** pan na strip de ações (B58 ✓ escondeu a barra e o pan sumiu — cortar = ações fora da dobra inacessíveis no mobile); **B65** âncora inferior mobile (B46 ✓ não ancorou — cortar = thumb zone continua falhando no celular); **B66** focus limpa busca (cortar = strip compete com o gesto de buscar até digitar — emenda explícita de produto); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); **B62** simplificar sinal (schema — cortar = wizard B63 e B26 seguem exigindo Fonte/Triangulado); ~~**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`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). +**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); **B65** âncora inferior mobile (B46 ✓ não ancorou — cortar = thumb zone continua falhando no celular); **B66** focus limpa busca (cortar = strip compete com o gesto de buscar até digitar — emenda explícita de produto); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); **B62** simplificar sinal (schema — cortar = wizard B63 e B26 seguem exigindo Fonte/Triangulado); ~~**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`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). **Cortes seguros** (se o prazo apertar, nesta ordem): **B68** sugestões na busca aberta (digitação B48 continua; cortar = empty mudo no focus até folga); **B64** wizard tendência standalone (A1 ainda pode embutir tendência via B24 ✓ / passo curto; cortar = sem grid colorido + prefill até folga); **B63** wizard sinal standalone (A1 ainda pode embutir select pós-B62; cortar = sem grid/Drawer de tipos até folga); **B57** delta 7d (Início continua com total+cobertura do B56; Δ era futuro de propósito); **B56** resumo no Início (Quadro já tem os mesmos agregados — cortar = Início sem briefing numérico); **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **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~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (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~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **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~~ (entregue 2026-07-26); **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~~ (entregue 2026-07-26); **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~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback 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~~ (entregue 2026-07-26); **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); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**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. diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx index 536b5867..3a93e406 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx @@ -1,12 +1,34 @@ 'use client' +import { useCallback, useRef, type MouseEvent, type PointerEvent } from 'react' + import { CampaignHomeActionButton, type CampaignHomeActionButtonProps, } from '@/components/campaign/dashboard/CampaignHomeActionButton' import { TooltipProvider } from '@/components/ui/tooltip' +import { useCoarsePointer } from '@/lib/campaignCoarsePointer' import { cn } from '@/lib/utils' +/** Horizontal pan threshold (px) — below this, child link/button clicks still fire. */ +export const HOME_ACTION_STRIP_DRAG_THRESHOLD_PX = 8 + +type DragSession = { + pointerId: number + startX: number + startScrollLeft: number + dragging: boolean +} + +const setScrollerDragging = (el: HTMLDivElement | null, dragging: boolean) => { + if (!el) return + if (dragging) { + el.dataset.dragging = 'true' + } else { + delete el.dataset.dragging + } +} + export const CampaignHomeActionStrip = ({ actions, ariaLabel = 'Ações rápidas', @@ -15,22 +37,116 @@ export const CampaignHomeActionStrip = ({ actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[] ariaLabel?: string className?: string -}) => ( - -
-
    - {actions?.map(({ id, ...button }) => ( -
  • - -
  • - ))} -
-
-
-) +}) => { + const isCoarsePointer = useCoarsePointer() + const scrollerRef = useRef(null) + const dragRef = useRef(null) + const suppressClickRef = useRef(false) + + const endDragSession = useCallback((pointerId: number) => { + const session = dragRef.current + if (!session || session.pointerId !== pointerId) return + + const el = scrollerRef.current + if (el && typeof el.hasPointerCapture === 'function' && el.hasPointerCapture(pointerId)) { + el.releasePointerCapture(pointerId) + } + + if (session.dragging) { + suppressClickRef.current = true + } + + setScrollerDragging(el, false) + dragRef.current = null + }, []) + + const onPointerDown = useCallback( + (event: PointerEvent) => { + if (isCoarsePointer || event.button !== 0) return + + const el = scrollerRef.current + if (!el) return + + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: el.scrollLeft, + dragging: false, + } + suppressClickRef.current = false + if (typeof el.setPointerCapture === 'function') { + el.setPointerCapture(event.pointerId) + } + }, + [isCoarsePointer], + ) + + const onPointerMove = useCallback((event: PointerEvent) => { + const session = dragRef.current + if (!session || session.pointerId !== event.pointerId) return + + const el = scrollerRef.current + if (!el) return + + const deltaX = event.clientX - session.startX + + if (!session.dragging) { + if (Math.abs(deltaX) < HOME_ACTION_STRIP_DRAG_THRESHOLD_PX) return + session.dragging = true + setScrollerDragging(el, true) + } + + el.scrollLeft = session.startScrollLeft - deltaX + event.preventDefault() + }, []) + + const onPointerEnd = useCallback( + (event: PointerEvent) => { + endDragSession(event.pointerId) + }, + [endDragSession], + ) + + const onClickCapture = useCallback((event: MouseEvent) => { + if (!suppressClickRef.current) return + event.preventDefault() + event.stopPropagation() + suppressClickRef.current = false + }, []) + + const finePointerPanProps = isCoarsePointer + ? {} + : { + onPointerDown, + onPointerMove, + onPointerUp: onPointerEnd, + onPointerCancel: onPointerEnd, + onClickCapture, + } + + return ( + +
+
    + {actions?.map(({ id, ...button }) => ( +
  • + +
  • + ))} +
+
+
+ ) +} diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index af55a922..95604bff 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -6,7 +6,10 @@ import { CampaignHomeActionButton, type CampaignHomeActionButtonProps, } from '@/components/campaign/dashboard/CampaignHomeActionButton' -import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' +import { + CampaignHomeActionStrip, + HOME_ACTION_STRIP_DRAG_THRESHOLD_PX, +} from '@/components/campaign/dashboard/CampaignHomeActionStrip' import { TooltipProvider } from '@/components/ui/tooltip' import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer' import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress' @@ -150,6 +153,20 @@ describe('CampaignHomeActionButton', () => { }) describe('CampaignHomeActionStrip', () => { + const manyActions = Array.from({ length: 8 }, (_, index) => ({ + label: `Ação ${index + 1}`, + icon: BarChart3, + })) + + const getScroller = (container: HTMLElement) => + container.querySelector('[aria-label="Ações rápidas"]') as HTMLDivElement + + const mockScrollerOverflow = (scroller: HTMLDivElement) => { + Object.defineProperty(scroller, 'scrollWidth', { value: 800, configurable: true }) + Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true }) + scroller.scrollLeft = 0 + } + it('renders a horizontal scroller with list semantics', () => { const { container } = render( { />, ) - const scroller = container.querySelector('[aria-label="Ações rápidas"]') + const scroller = getScroller(container) expect(scroller?.className).toContain('overflow-x-auto') expect(scroller?.className).toContain('scrollbar-width:none') expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy() }) + + it('scrolls horizontally on pointer-fine drag past the threshold', () => { + const { container } = render() + const scroller = getScroller(container) + mockScrollerOverflow(scroller) + + const startX = 120 + const dragDelta = HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 20 + + fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 1 }) + fireEvent.pointerMove(scroller, { + button: 0, + clientX: startX - dragDelta, + pointerId: 1, + }) + expect(scroller.scrollLeft).toBe(dragDelta) + expect(scroller.dataset.dragging).toBe('true') + + fireEvent.pointerUp(scroller, { pointerId: 1 }) + expect(scroller.dataset.dragging).toBeUndefined() + }) + + it('does not pan on coarse pointer — touch uses native overflow scroll', () => { + resetCampaignCoarsePointerForTests() + matchMediaMock.mockImplementation((query: string) => ({ + matches: query.includes('coarse'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + const onClick = vi.fn() + const { container } = render( + , + ) + const scroller = getScroller(container) + mockScrollerOverflow(scroller) + + fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 4 }) + fireEvent.pointerMove(scroller, { button: 0, clientX: 50, pointerId: 4 }) + fireEvent.pointerUp(scroller, { pointerId: 4 }) + expect(scroller.scrollLeft).toBe(0) + + fireEvent.click(screen.getByRole('button', { name: 'Registrar' })) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('suppresses child click after a drag gesture', () => { + const onClick = vi.fn() + const { container } = render( + , + ) + const scroller = getScroller(container) + mockScrollerOverflow(scroller) + + fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 2 }) + fireEvent.pointerMove(scroller, { button: 0, clientX: 100, pointerId: 2 }) + fireEvent.pointerUp(scroller, { pointerId: 2 }) + + fireEvent.click(screen.getByRole('button', { name: 'Registrar' })) + expect(onClick).not.toHaveBeenCalled() + }) + + it('allows child click when movement stays below the drag threshold', () => { + const onClick = vi.fn() + const { container } = render( + , + ) + const scroller = getScroller(container) + mockScrollerOverflow(scroller) + + const startX = 80 + fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 3 }) + fireEvent.pointerMove(scroller, { + button: 0, + clientX: startX - HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 2, + pointerId: 3, + }) + fireEvent.pointerUp(scroller, { pointerId: 3 }) + + fireEvent.click(screen.getByRole('button', { name: 'Registrar' })) + expect(onClick).toHaveBeenCalledTimes(1) + }) }) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 74c560cc..9fca5228 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -565,7 +565,7 @@ describe('admin bypasses carry a justification comment', () => { ['src/utilities/electionCandidateOptions.ts', 1], ['src/utilities/territory/loadTerritoryOverview.ts', 1], ['src/utilities/municipality/municipalityCandidateComparison.ts', 2], - ['src/utilities/municipality/municipalityElectoralBaseline.ts', 9], + ['src/utilities/municipality/municipalityElectoralBaseline.ts', 8], ['src/utilities/onda0Provision.ts', 4], ['src/utilities/stateDeputyData.ts', 1], ['src/utilities/visit/visitPlannerData.ts', 1],