From 6fc3bd04b2aa5ec6432c9c7a9b74a00c7e6805a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 22:15:39 -0300 Subject: [PATCH] =?UTF-8?q?Register=20B39=E2=80=93B42:=20session=20persist?= =?UTF-8?q?ence,=20biometrics,=20and=20municipality=20list=20UX.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/lembrar-de-mim-sessao-campanha.md | 106 +++++++++++++++ docs/plans/login-biometrico-campanha.md | 110 +++++++++++++++ .../polimento-mobile-lista-municipios.md | 125 ++++++++++++++++++ .../scroll-horizontal-lista-municipios.md | 104 +++++++++++++++ docs/roadmap.md | 24 +++- 6 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 docs/plans/lembrar-de-mim-sessao-campanha.md create mode 100644 docs/plans/login-biometrico-campanha.md create mode 100644 docs/plans/polimento-mobile-lista-municipios.md create mode 100644 docs/plans/scroll-horizontal-lista-municipios.md diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 03d33673..946c6dee 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40. - **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md). - **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md). - **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md). diff --git a/docs/plans/lembrar-de-mim-sessao-campanha.md b/docs/plans/lembrar-de-mim-sessao-campanha.md new file mode 100644 index 00000000..2d8827c0 --- /dev/null +++ b/docs/plans/lembrar-de-mim-sessao-campanha.md @@ -0,0 +1,106 @@ +# Lembrar de mim / sessão longa em `/campanha` + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B39) +Impeccable: B — encaixe no `LoginForm` + cookie `campaign-token`; sem rota nova +Appetite: ~0,5 dia eng; checkbox + duas durações de JWT/cookie + testes; sem migration +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (register `product` — Field Desk; uso em campo sob pressão) / `DESIGN.md` (tema `data-theme='campaign'`) · shell de auth existente (`CampaignAuthCardHeader`, `LoginForm`). + +Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. + +Brief compacto: + +- **Persona / contexto:** Assessor / CG no celular ou PWA instalado (D1 ✓), abrindo `/campanha` várias vezes no dia; hoje a sessão cai em ~2 h e o login vira atrito diário. +- **Job principal:** permanecer autenticado no dispositivo próprio pelo dia de trabalho (default) ou por dias (opt-in "Lembrar de mim"), sem enfraquecer o logout explícito nem o isolamento do cookie. +- **Estratégia de cor:** Restrained — checkbox + label no formulário existente; sem hero de "segurança". +- **Edit where you see:** não — superfície de auth. +- **Anti-goals:** "ficar logado para sempre" sem opt-in; segundo cookie de sessão; armazenar senha no `localStorage`; misturar sessão de `users` (`/admin`). + +## Dados → decisão → apresentação + +Dados: N/A — auth/sessão; nenhum KPI/série/mapa. + +## Contexto + +A sessão de `/campanha` é o cookie httpOnly `campaign-token` (`path: '/campanha'`, `sameSite: 'lax'`, `secure` em produção), setado por [`setCampaignAuthCookie`](../../src/utilities/campaignAuth.ts) após `payload.login` em [`loginCampaign`](<../../src/app/(campaign)/campanha/actions/auth.ts>). O `maxAge` do cookie espelha `campaignUser.config.auth.tokenExpiration`, com fallback **`DEFAULT_TOKEN_EXPIRATION = 7200`** (2 horas). A collection [`CampaignUser`](../../src/collections/CampaignUser.ts) **não declara** `tokenExpiration` hoje — Payload + o fallback deixam a sessão curta demais para mesa/campo. Não há checkbox "Lembrar de mim"; o `LoginForm` só pede identificador + senha. + +Pedido de produto (2026-07-26): o usuário é deslogado com frequência; oferecer "Lembrar de mim neste dispositivo" e manter a sessão por mais tempo. + +PWA (D1 ✓): o SW envia o cookie automaticamente nos `fetch`; logout já limpa caches (`clearCampaignPwaCaches`). Sessão mais longa beneficia o PWA instalado sem mudar o modelo de auth. + +## Objetivos + +- No login, checkbox **"Lembrar de mim neste dispositivo"** (default **desmarcado**). +- Sessão **sem** lembrar: duração de **dia de trabalho** (recomendação: **8 h**), não 2 h. +- Sessão **com** lembrar: duração **longa** (recomendação: **14 dias**), JWT + cookie alinhados. +- Logout explícito continua invalidando o cookie (`maxAge: 0`) e os caches PWA. +- Guardrails: sem migration, sem collection, sem Consent novo; cookie permanece httpOnly / path `/campanha` / isolado de `payload-token`; `leader` e staff no mesmo contrato. + +## Decisões travadas + +- **Duas durações via checkbox, não um único `tokenExpiration` global longo.** O caro de reverter é o tempo de vida do JWT em dispositivo compartilhado: opt-in explícito para a faixa longa; default só alonga o dia de trabalho. **Rejeitado:** (a) só subir o default para 14/30 dias sem checkbox (dispositivo de mesa compartilhada fica aberto demais); (b) manter 2 h e só alongar com "lembrar" (o atrito reportado acontece no default); (c) sliding session / refresh token (segundo protocolo, appetite e superfície de ataque). +- **Cookie `maxAge` = `tokenExpiration` do login que emitiu o JWT.** Continua o padrão de `setCampaignAuthCookie`; o login passa a escolher qual expiração pedir ao Payload (ou emitir com `exp` coerente). **Rejeitado:** cookie longo com JWT curto (usuário "parece" logado e falha no próximo `getCampaignUser`); cookie curto com JWT longo (sessão morre cedo sem o token ter expirado). +- **Sem persistir a senha no cliente.** "Lembrar" = duração do token, não Credential Manager nem `localStorage` de senha. Biometria / WebAuthn fica em **B40**. **Rejeitado:** autofill como "feature" de produto (já é do browser); guardar credenciais em plain storage. +- **i18n e naming** (AGENTS.md): identificadores `rememberMe`, `CAMPAIGN_SESSION_TTL_SHORT` / `_LONG` (ou nomes equivalentes); copy pt-BR "Lembrar de mim neste dispositivo". + +## Questões em aberto + +- **Durações exatas (8 h / 14 d)?** **Opções:** A) 8 h + 14 d _(recomendado)_ | B) 12 h + 30 d | C) 4 h + 7 d. **Recomendação:** A — dia de trabalho tipico + quinzena cobrindo giro sem re-login diário; 30 d é agressivo com PII de apoiadores ainda sob hold jurídico. _(assumido — validar com produto / assessoria se o lote jurídico quiser teto menor.)_ + +## Abordagem proposta + +```mermaid +flowchart LR + form["LoginForm + checkbox rememberMe"] --> action["loginCampaign"] + action --> login["payload.login
tokenExpiration short|long"] + login --> cookie["setCampaignAuthCookie
maxAge = tokenExpiration"] + cookie --> gate["getCampaignUser
(layout app)"] +``` + +Componentes: + +- **`src/lib/schemas/campaign-login.ts`**: campo opcional `rememberMe` (boolean / "on"). +- **`LoginForm.tsx`**: `Checkbox` + label; `name="rememberMe"`; `autoComplete` inalterado. +- **`loginCampaign` / `setCampaignAuthCookie`**: aceitar TTL explícito; alinhar JWT + cookie; constantes numéricas num módulo pequeno (`campaignSessionTtl.ts` em `lib/` se puro). +- **`CampaignUser` auth config**: se Payload exigir `tokenExpiration` estático na collection, usar o **máximo** (longo) na config e emitir tokens com `exp` menor no caminho curto — ou o mecanismo que o Payload 3 expuser sem segundo cookie. Confirmar na implementação lendo a API de `payload.login` / collection auth (depth check: não inventar refresh token). +- **Testes:** unit do schema; int do login com/sem remember (cookie `maxAge` / claim `exp` se observável); e2e smoke do checkbox opcional. +- **Migration:** nenhuma. + +Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action existente. Não cria sessão paralela nem collection de refresh. + +## Dependências + +- Nenhuma dura de outro plano. Soft: **B40** (login biométrico) pousa no mesmo `LoginForm` depois; **D1 ✓** PWA já convive com o cookie. + +## Não escopo + +- WebAuthn / digital / Face ID → **B40**. +- "Manter logado" em `/admin` (`users`) → Admin Payload / outro ciclo. +- Sliding expiration, "sessão ativa enquanto a aba estiver aberta", SSO. +- Mudar `path`/`sameSite` do cookie ou unificar com `payload-token`. + +## Rabbit holes + +- **Refresh-token / dual-cookie.** Se alguém “só completar” com refresh: segundo segredo, rotação, invalidação no logout, testes de race. **Mitigação:** uma JWT + um cookie, duas durações no emit. +- **`tokenExpiration` estático na collection vs por login.** Payload pode não aceitar TTL por request. **Mitigação:** medir na Fase 1 da implementação; se só houver TTL estático, config = longo e o caminho curto usa cookie `maxAge` menor **somente se** o JWT curto for emitível — senão documentar e cair para default único alongado + checkbox como no-op visual (falha de produto: não mentir). Preferir falhar o gate a mentir a duração. + +## Adiado com gatilho + +- **TTL 30 dias ou "até eu sair".** Revisitar quando: mesa pedir após uso real com B39 + assessoria confirmar teto LGPD para sessão em dispositivo com PII. +- **Aviso "este dispositivo será lembrado" no primeiro remember.** Revisitar quando: critique/R6 acusar surpresa em tablet compartilhado. + +## Referências + +- `docs/roadmap.md` (Trilha B · B39) +- `src/utilities/campaignAuth.ts` — cookie + `DEFAULT_TOKEN_EXPIRATION` +- `src/app/(campaign)/campanha/actions/auth.ts` — `loginCampaign` / logout +- `src/app/(campaign)/campanha/login/LoginForm.tsx` +- `src/collections/CampaignUser.ts` — auth +- `docs/plans/pwa-campanha.md` — cookie + logout limpa cache +- AGENTS.md — Campaign auth +- `PRODUCT.md` / `DESIGN.md` — Field Desk diff --git a/docs/plans/login-biometrico-campanha.md b/docs/plans/login-biometrico-campanha.md new file mode 100644 index 00000000..236f41ee --- /dev/null +++ b/docs/plans/login-biometrico-campanha.md @@ -0,0 +1,110 @@ +# Login com biometria (WebAuthn) em `/campanha` + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B40) +Impeccable: C — fluxo novo na tela de login + registro pós-primeiro-login; brief compacto +Appetite: ~1,5–2 dias eng; WebAuthn platform authenticator + storage server-side + UI login/perfil; migration leve +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (Field Desk; PWA de campo) / `DESIGN.md` · tema `data-theme='campaign'` · `LoginForm` + shell de auth. + +Na implementação (`implement-roadmap-item`): **shape → craft → critique → polish** (fluxo multi-passo; shape compacto basta). + +Brief compacto: + +- **Persona / contexto:** Assessor no PWA/iPhone com Face ID / Android com digital, depois do primeiro login com senha; quer reabrir o app sem digitar senha a cada sessão curta. +- **Job principal:** no dispositivo já confiável, autenticar com biometria do SO e obter sessão `campaign-token` válida. +- **Estratégia de cor:** Restrained — botão secundário "Entrar com digital / Face ID" abaixo do form; sem branding de "passwordless startup". +- **Edit where you see:** não — auth. +- **Anti-goals:** substituir senha por completo no v1; biometria como único fator sem enrollment consciente; SDK nativo fora do browser; misturar com auth de `/admin`. + +## Dados → decisão → apresentação + +Dados: N/A — credenciais WebAuthn; nenhum KPI/série/mapa. + +## Contexto + +Hoje o único caminho de login é senha (`payload.login` → cookie `campaign-token`). **B39** alonga a sessão (checkbox "Lembrar de mim"), mas quando a sessão expira o usuário digita de novo. Em dispositivos com Secure Enclave / StrongBox, o browser expõe **WebAuthn** (`navigator.credentials`) com *platform authenticator* — a UI do SO pede digital/Face ID e devolve uma asserção verificável no servidor. + +Pedido de produto (2026-07-26): após o primeiro login, oferecer login com digital nos aparelhos que tiverem leitor. + +Não há WebAuthn / passkey / Credential Management de produto no código hoje. O PWA (D1 ✓) é o melhor host (standalone), com as mesmas limitações do Safari iOS (WebAuthn suportado em versões recentes; validar no craft). + +## Objetivos + +- Após login com senha bem-sucedido **neste dispositivo**, oferecer enrollment opt-in ("Usar digital / Face ID neste aparelho"). +- Na próxima visita à tela de login (sessão expirada), se houver credencial local discoverable / allowCredentials, mostrar botão de biometria que completa login sem senha. +- Credenciais amarradas a um `campaignUser`; revogáveis (remover neste dispositivo / remover todas no `/campanha/perfil`). +- Sessão resultante = mesmo cookie `campaign-token` (TTL alinhado a B39: preferir o TTL "lembrar" no login biométrico, pois o dispositivo já é o opt-in de confiança). +- Guardrails: migration para armazenar credenciais; **sem** Consent novo (fator de auth da conta staff/leader já autenticada — não é coleta de PII de apoiador); access: só o dono gerencia as próprias credenciais; `overrideAccess: false` nas mutações com `user`. + +## Decisões travadas + +- **WebAuthn platform authenticator (passkey/device-bound), não "ler a digital e comparar com hash nosso".** O SO faz a biometria; o app só verifica a asserção. **Rejeitado:** (a) Credential Management API só para autofill de senha (não é feature de produto); (b) armazenar senha cifrada e "desbloquear" com WebAuthn sem asserção server-side (pior modelo); (c) SDK nativo / Capacitor (fora do app Next). +- **Enrollment só depois de sessão senha válida** (ou reset de senha recente). **Rejeitado:** registrar passkey a partir de e-mail mágico sem senha no v1 (explode recovery). +- **Storage server-side das credenciais** (collection ou campo array versionado em `campaignUser`) com `credentialID`, `publicKey`, `counter`, `transports`, label do dispositivo. **Rejeitado:** só `localStorage` (não autentica no servidor); depender só do password manager do browser sem registro nosso (não controlamos UX "Entrar com digital"). +- **Login biométrico emite o TTL longo (B39 "lembrar")** — o enrollment no dispositivo *é* o consentimento de confiança. **Rejeitado:** TTL curto no biométrico (derrota o job); exigir checkbox remember no fluxo biométrico. +- **i18n e naming:** `campaignWebAuthn`, `passkey`/`webauthnCredential` nos identificadores; copy pt-BR ("Entrar com digital ou Face ID", "Ativar neste aparelho"). + +## Questões em aberto + +- **Collection nova `campaignWebAuthnCredential` vs array no `campaignUser`?** **Opções:** A) collection join (1:N, admin-visível, delete fácil) | B) array no user. **Recomendação:** A — contadores WebAuthn e revogação por dispositivo ficam limpos; access `create/read/delete` = self. _(assumido.)_ +- **Mostrar o botão biométrico antes de digitar o identificador (discoverable credentials) ou depois?** **Opções:** A) botão sempre visível se o browser reportar platform authenticator + há credencial no device | B) só após blur do identificador. **Recomendação:** A com conditional UI (`PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable`). + +## Abordagem proposta + +```mermaid +flowchart LR + pwd["Login senha
(B39)"] --> offer["Prompt enrollment
opt-in"] + offer --> reg["navigator.credentials.create"] + reg --> store["POST /campanha/webauthn/register
salva credential"] + loginUI["LoginForm"] --> get["GET challenge"] + get --> assert["navigator.credentials.get"] + assert --> verify["POST /campanha/webauthn/login"] + verify --> cookie["setCampaignAuthCookie
TTL longo"] +``` + +Componentes: + +- **Lib pura** de challenge/verify (preferir pacote maduro tipo `@simplewebauthn/server` + browser pair — depth: não reinventar CBOR/COSE). +- **Routes / server actions** sob `/campanha`: challenge de registro, registro, challenge de login, verificação → cookie. +- **UI:** `LoginForm` (botão biométrico); banner/dialog pós-login ou toggle em `/campanha/perfil`. +- **Migration:** `pnpm migrate:create add_campaign_webauthn_credentials` (collection ou fields). +- **Testes:** unit dos helpers de verify com fixtures; int do register/login happy path + reject counter replay; e2e condicional (mock de WebAuthn no Playwright). + +Depth check: reusa `setCampaignAuthCookie` / `getCampaignUser`; não cria segundo cookie. Não acoplar ao D2 push. + +## Dependências + +- Soft: **B39** (sessão longa / "lembrar") — ideal landar antes para TTL e copy coerentes no mesmo `LoginForm`. Sem B39, B40 ainda funciona com o default atual. +- Soft: D1 ✓ PWA (melhor host; não bloqueia). + +## Não escopo + +- Remover senha / passwordless-only. +- Passkeys cross-device (iCloud/Google sync) como requisito — aceitar se o SO oferecer, não construir conta de sync. +- Biometria para `users` / `/admin`. +- MFA obrigatória para coordenador. + +## Rabbit holes + +- **Attestation enterprise / MDM.** **Mitigação:** `none` attestation no v1. +- **Polyfill WebAuthn em browsers velhos.** **Mitigação:** feature-detect; esconder botão; senha continua. +- **Recovery se o aparelho morre.** **Mitigação:** senha + "esqueci senha" já existem; listar/revogar credenciais no perfil. + +## Adiado com gatilho + +- **Passkeys sincronizadas multi-device como copy de produto.** Revisitar quando: mesa pedir login no laptop novo sem senha após enrollment no celular. +- **Exigir re-senha a cada N dias mesmo com biometria.** Revisitar se assessoria jurídica pedir após lote Onda 0. + +## Referências + +- `docs/roadmap.md` (Trilha B · B40) +- [`lembrar-de-mim-sessao-campanha.md`](lembrar-de-mim-sessao-campanha.md) (B39) +- `src/utilities/campaignAuth.ts`, `LoginForm.tsx`, `actions/auth.ts` +- `docs/plans/pwa-campanha.md`, `docs/plans/reset-senha-foto-perfil.md` +- AGENTS.md — Campaign auth +- `PRODUCT.md` / `DESIGN.md` diff --git a/docs/plans/polimento-mobile-lista-municipios.md b/docs/plans/polimento-mobile-lista-municipios.md new file mode 100644 index 00000000..94b5d505 --- /dev/null +++ b/docs/plans/polimento-mobile-lista-municipios.md @@ -0,0 +1,125 @@ +# Polimento mobile da lista de municípios (drawers + card compacto) + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B42) +Impeccable: B — encaixe nos cards `md:hidden` + controles Tendência/Assessores; sem rota nova +Appetite: ~1 dia eng; Drawer nos dois controles (padrão B26) + densificar card + tap-to-open +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (Edit where you see, Feel the action, Clarity under pressure) / `DESIGN.md` · regras `campanha-edit-where-you-see` / `campanha-action-feedback` · precedente **B26 ✓** (`MunicipalityListSignalControl`: Popover desktop / Drawer mobile). + +Na implementação: craft compacto → critique → polish. + +Brief compacto: + +- **Persona / contexto:** Assessor em campo no celular, varrendo a fila; o card está alto demais e os popovers de Tendência/Assessores competem com o teclado (o Sinal já abre em Drawer). +- **Job principal:** editar tendência/assessores no mesmo gesto confortável do sinal; varrer mais municípios por viewport; abrir o detalhe com um toque no card. +- **Estratégia de cor:** Restrained — sem novo chrome; Drawer do kit (`src/components/ui/Drawer`). +- **Edit where you see:** sim — mantém edição in-card; só muda o container mobile. +- **Anti-goals:** Popover genérico "sempre Drawer"; spreadsheet; segundo botão "Abrir" depois do card inteiro ser clicável; perder stopPropagation nos controles editáveis. + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Sim, só **reapresentação** do que o card já mostra (classe E10, cobertura E8). +- **Decisões desbloqueadas:** Assessor/CG — "este município merece abrir agora?" com menos scroll; edição de tendência/assessores sem luta com teclado. +- **Forma escolhida:** badge de classe sem prosa; cobertura em layout **`compact`** (número + déficit curto, como desktop). **Rejeitado:** tirar Classe/Cobertura do card (ainda informam a fila); chart/KPI novo. + +## Contexto + +Cards mobile em [`MunicipalityList.tsx`](../../src/components/campaign/municipality/MunicipalityList.tsx) (`data-view="mobile-cards"`, `md:hidden`): + +- **Classe** usa `TerritorialClassReadout` `layout="card"` — mostra o "por quê" em texto (`text-xs`); na tabela o mesmo texto é `sr-only` + tooltip B23. +- **Cobertura da meta** usa `MunicipalityListGoalCoverageCell` **sem** `layout="compact"` — soletra déficit + cenário; desktop passa `compact`. +- Botão outline **"Abrir município"** no rodapé do card. +- **Tendência** (`MunicipalityListTrendControl`) e **Assessores** (`MunicipalityListAdvisorsControl`) abrem **Popover** em qualquer viewport. +- **Último sinal** (`MunicipalityListSignalControl`) já distingue `variant: 'popover' | 'sheet'` — mobile usa Drawer (**B26 ✓**). + +Pedido de produto (2026-07-26), agrupado neste item (mesma superfície mobile): + +1. Tendência → Drawer no mobile, Popover no desktop. +2. Assessores → idem. +3. Card menor: sem descrição da classe; cobertura só no formato número (compact); sem botão "Abrir município"; toque no card abre o município. + +## Objetivos + +- `MunicipalityListTrendControl` e `MunicipalityListAdvisorsControl`: **Popover em `md+`**, **Drawer (bottom sheet) abaixo de `md`**, espelhando o contrato visual/a11y do sinal (título + nome do município no header do Drawer). +- Auto-save / endpoints JSON de B24/B27 **inalterados** — só o container muda. +- Card mobile: Classe como na tabela (badge + `sr-only` why); Cobertura com `layout="compact"`; remover o `Button` "Abrir município"; o `
` (ou região equivalente) navega para `/campanha/municipios/[slug]` no toque/clique **fora** dos controles interativos. +- Controles internos (tendência, votos, sinal, assessores, `TerritoryLink`) **não** disparam a navegação (`stopPropagation` / `pointer-events` / link overlay com buracos — escolher o padrão mais simples no craft). +- Guardrails: sem migration/action/Consent; desktop table fora (exceto se o controle compartilhado ganhar o branch de viewport); access inalterado. + +## Decisões travadas + +- **Um item só para drawers + card** (lote 2026-07-26). Mesma viewport, mesmos arquivos, appetite cabe em ~1 d. **Rejeitado:** três fill-ins separados (custo de coordenação > ganho); absorver em R6 (atrasa quick win de campo). +- **Mesmo breakpoint do sinal / cards (`md` = 768)** via padrão já usado (`md:hidden` cards, `useIsMobile` se o controle precisar montar um único tree). **Rejeitado:** breakpoint diferente por controle. +- **Card inteiro como hit target de navegação, não botão extra.** **Rejeitado:** manter o botão "Abrir" + card clicável (dois affordances); só remover o botão sem tornar o card clicável (regressão de descoberta). +- **Classe no card = paridade com tabela** (`layout="table"` ou flag `hideWhy`). O research §6.4 ("nunca só o label") continua via `sr-only` + eventual tooltip/tap no badge se B23 já cobre — no card mobile, `sr-only` basta no v1 (paridade pedida). **Rejeitado:** tirar a Classe do card. +- **i18n/naming:** reusar `variant: 'popover' | 'sheet'` do sinal ou extrair helper `useCampaignOverlayMode()` no 3º call site — depth: se Trend+Advisors+Signal forem o 3º, extrair; senão copiar o branch uma vez. + +## Questões em aberto + +- **Extrair shell Popover|Drawer compartilhado agora?** **Opções:** A) branch local nos dois controles (copiar B26) | B) extrair `CampaignResponsiveOverlay` já. **Recomendação:** A na primeira PR; se o diff duplicar >~40 linhas iguais, extrair no `/simplify` do mesmo item (3º call site = sinal + estes dois). _(assumido.)_ + +## Abordagem proposta + +```mermaid +flowchart TB + subgraph desktop [md+] + T1[Trend Popover] + A1[Advisors Popover] + end + subgraph mobile [lt md] + T2[Trend Drawer] + A2[Advisors Drawer] + Card[Card compacto
tap → detalhe] + end + Signal[SignalControl sheet] -.padrão.-> T2 + Signal -.padrão.-> A2 +``` + +Componentes: + +- **`MunicipalityListTrendControl.tsx`**: espelhar estrutura de `MunicipalityListSignalControl` (conteúdo do form numa função; Popover vs Drawer). +- **`MunicipalityListAdvisorsControl.tsx`**: idem (chips + Command dentro do Drawer; cuidado com foco/teclado virtual). +- **`MunicipalityList.tsx` (cards):** `TerritorialClassReadout` sem why visível; `GoalCoverageCell layout="compact"`; remover `Button`/`Link` "Abrir município"; navegação no card com exclusão dos controles. +- **Testes:** unit/e2e leves — no mobile viewport, trigger de tendência abre dialog/sheet (role); tap no título navega; tap no controle não navega. +- **Migration:** nenhuma. + +Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de overlay novo até o simplify provar duplicação. + +## Dependências + +- Duras: nenhuma. Consome **B24 ✓** / **B27 ✓** / **B26 ✓**. +- Soft: **B41** (scroll desktop) — paralelo; não bloqueia. + +## Não escopo + +- Scroll horizontal / sticky da tabela desktop → **B41**. +- Mudar auto-save, endpoints, ou schema de tendência/assessores. +- Paridade Drawer em outras listas (lideranças) — Adiado. +- Seletor de colunas / filtros salvos. + +## Rabbit holes + +- **Card clicável + botões internos = navegação acidental.** **Mitigação:** testes e2e do gesto; `stopPropagation` nos triggers; critique em device real. +- **Command dentro de Drawer (foco trap / teclado).** **Mitigação:** espelhar padrões Radix já usados no Sheet do sidebar; testar Android Chrome. +- **Extrair overlay genérico cedo demais.** **Mitigação:** decisão A acima + simplify. + +## Adiado com gatilho + +- **Tooltip/tap da Classe no card mobile** (mostrar o why sob demanda). Revisitar se a mesa sentir falta após a paridade com desktop. +- **Drawer para votos estimados** no card. Revisitar se o Popover de votos reclamar do teclado como tendência. + +## Referências + +- `docs/roadmap.md` (Trilha B · B42) +- `src/components/campaign/municipality/MunicipalityList.tsx` +- `MunicipalityListTrendControl.tsx`, `MunicipalityListAdvisorsControl.tsx`, `MunicipalityListSignalControl.tsx` +- `MunicipalityListGoalCoverageCell.tsx` +- `docs/plans/registrar-sinal-lista-municipios.md` (B26) +- `docs/plans/autosave-tendencia-lista-municipios.md` (B24) +- `docs/plans/combobox-assessores-lista-municipios.md` (B27) +- `PRODUCT.md` / `DESIGN.md` diff --git a/docs/plans/scroll-horizontal-lista-municipios.md b/docs/plans/scroll-horizontal-lista-municipios.md new file mode 100644 index 00000000..bee8dc25 --- /dev/null +++ b/docs/plans/scroll-horizontal-lista-municipios.md @@ -0,0 +1,104 @@ +# Scroll horizontal + coluna Município fixa na lista de municípios + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B41) +Impeccable: B — encaixe em `CampaignTable` / `MunicipalityList` (desktop/tablet); sem rota nova +Appetite: ~0,5–0,75 dia eng; overflow-x + sticky left na 1ª coluna; reusa padrão de `TerritoryList` +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (Clarity under pressure; anti spreadsheet) / `DESIGN.md` · sistema de listas Pass 2 W1 · precedente **B21 ✓** (`TerritoryList`). + +Na implementação: craft compacto → critique → polish. + +Brief compacto: + +- **Persona / contexto:** CG/assessor em iPad ou laptop estreito na fila de `/campanha/municipios` (10+ colunas staff); a tabela estoura a viewport e empurra o layout. +- **Job principal:** rolar as colunas na horizontal **dentro** da lista, mantendo o nome do município visível (âncora da linha). +- **Estratégia de cor:** Restrained — fundo opaco na célula sticky (`bg-background` / zebra se houver). +- **Edit where you see:** não muda o contrato dos controles; só o scroller. +- **Anti-goals:** voltar a um scroller vertical interno que quebre o header sticky do B16; esconder colunas à força (isso é **B17**); inventar data-grid. + +## Dados → decisão → apresentação + +Dados: N/A — chrome de tabela; as métricas já existentes não mudam de forma. + +## Contexto + +Em **B16 ✓**, a lista de municípios optou por `containerClassName="overflow-x-visible"` para o `sticky top-0` dos `th` resolver contra o scroller do `
` (`campaign-content-scroll`). Trade-off documentado no plano: *"uma tabela mais larga que a viewport passa a rolar junto com a página"*. Com as colunas de E8/E9/E10/B27, em tablet/desktop a tabela **estoura** a tela — pedido de produto 2026-07-26. + +O precedente já existe na página de territórios (**B21 ✓**): [`TerritoryList.tsx`](../../src/components/campaign/municipality/TerritoryList.tsx) usa `overflow-x-auto` e [`TerritoryListColumns.tsx`](../../src/components/campaign/municipality/TerritoryListColumns.tsx) marca a 1ª coluna com `sticky left-0 z-20 … bg-background`. O plano de reorder ([reordenar-colunas-lista-municipios.md](reordenar-colunas-lista-municipios.md), fora de escopo) já travava *"Coluna Município fixa à esquerda"* como âncora — este item entrega essa âncora **sem** DnD. + +`CampaignTable` / `MunicipalityList` (trecho desktop `hidden md:block`) são o alvo. Cards mobile (`md:hidden`) ficam em **B42**. + +## Objetivos + +- Em `md+`, quando a tabela for mais larga que a viewport: **rolagem horizontal** no container da tabela (barra própria), sem estourar o shell. +- Coluna **Município** (nome) permanece **fixa à esquerda** durante o scroll horizontal (`sticky left-0` + fundo opaco + z-index acima das células que passam por baixo). +- Header sticky vertical (`top-0` do B16) **continua** funcionando, ou degrada de forma documentada e aceitável (ver Decisões). +- Popovers de célula (Tendência, Assessores, Sinal, votos) continuam portais Radix — não ficam clipados pelo `overflow-x-auto` (já é o motivo de vários planos preferirem Popover). +- Guardrails: sem migration/action/Consent; mobile cards inalterados neste item; **B17** (ocultar colunas) permanece complementar, não substituto. + +## Decisões travadas + +- **Scroller horizontal no container da tabela (`overflow-x-auto`), espelhando `TerritoryList`.** **Rejeitado:** (a) manter `overflow-x-visible` e rolar a página inteira (é o bug atual); (b) `min-w-0` + truncar todas as colunas (mata legibilidade da fila); (c) esperar **B17** esconder colunas (mitiga, não resolve tablet com muitas colunas default). +- **Sticky left só na coluna `name` (Município).** **Rejeitado:** sticky em Nome+2022 (complexidade de offsets `left`); sticky em todas as colunas "importantes". +- **Conviver sticky top (header) + sticky left (1ª coluna) + overflow-x no wrapper.** Canto superior-esquerdo precisa `z` maior e fundo opaco (padrão Territory). Se o header vertical e o scroll horizontal brigarem no browser X, preferir **horizontal correto + header que gruda no scroller interno** se necessário — medir no craft; não reinventar virtualização. **Rejeitado:** abandonar sticky top sem medir. +- **i18n/naming:** seam opcional `stickyFirstColumn` / classes na column def; sem copy nova. + +## Questões em aberto + +- **Extrair helper de classes sticky para `CampaignTable`?** **Opções:** A) só classes nas column defs de municípios (e Territory já tem as suas) | B) prop `stickyColumnId` no `CampaignTable`. **Recomendação:** A no v1 (<3 call sites distintos de API); B quando B29/outras listas pedirem o mesmo seam. _(assumido.)_ + +## Abordagem proposta + +```mermaid +flowchart LR + main["main campaign-content-scroll
vertical"] --> tableWrap["CampaignTable container
overflow-x-auto"] + tableWrap --> thName["th/td name
sticky left-0"] + tableWrap --> rest["demais colunas
rolam no eixo X"] +``` + +Componentes: + +- **`MunicipalityList.tsx`**: trocar `containerClassName="overflow-x-visible"` → `overflow-x-auto` (ou equivalente); classes sticky na coluna nome em `municipalityListColumns`. +- **`CampaignTable.tsx`**: só se precisar de seam mínimo para `head`/`cell` sticky (senão classes via `className` da column). +- **Teste unit** de contrato (coluna name traz sticky; container overflow-x-auto) — pin como em B22/B23. +- **Migration:** nenhuma. + +Depth check: copiar o padrão Territory, não um grid library. + +## Dependências + +- Soft: **B17** (menos colunas → menos scroll) — não bloqueia. +- Soft: **B38 ✓** (sidebar offcanvas em tablet) — já ajuda viewport; este item fecha o overflow restante. +- Nenhuma dura. + +## Não escopo + +- Redesign do card mobile / drawers → **B42**. +- Seletor de colunas → **B17**. +- Reorder DnD → fora de escopo ([reordenar-colunas…](reordenar-colunas-lista-municipios.md)). +- Sticky first column nas outras listas (lideranças/dobradinhas) — Adiado. + +## Rabbit holes + +- **Scroller vertical interno + sticky header.** Reverter o B16 inteiro. **Mitigação:** só `overflow-x`; vertical continua no `main`. +- **`position: sticky` dentro de `overflow` em Safari.** **Mitigação:** espelhar Territory (já em produção); testar iPad no critique. + +## Adiado com gatilho + +- **Sticky first column genérico no `CampaignTable`.** Revisitar no 3º call site (municípios + territórios + uma terceira). +- **Sombra/edge fade indicando mais colunas à direita.** Revisitar se critique acusar descoberta fraca do scroll. + +## Referências + +- `docs/roadmap.md` (Trilha B · B41) +- `src/components/campaign/municipality/MunicipalityList.tsx` +- `src/components/campaign/municipality/TerritoryList.tsx` / `TerritoryListColumns.tsx` +- `src/components/campaign/shared/CampaignTable.tsx` +- `docs/plans/filtros-no-header-lista-municipios.md` (trade-off B16) +- `docs/plans/pagina-territorios-identidade.md` (B21) +- `PRODUCT.md` / `DESIGN.md` diff --git a/docs/roadmap.md b/docs/roadmap.md index 712b5e89..727e6334 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **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); 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 (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [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 — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [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` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; 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` (entregue 2026-07-26), [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 (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [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 (entregue 2026-07-26), [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). +Atualizado em: 2026-07-26 (**B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **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); 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 (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [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 — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [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` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; 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` (entregue 2026-07-26), [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 (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [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 (entregue 2026-07-26), [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) @@ -117,6 +117,10 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; **Pass 2 W1 deixou trivial:** colunas já são dado (`CampaignTableColumn.id`/`mandatory`/`defaultVisible`); soft: B15 ✓ (ids) / B16 ✓ (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md) - **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); **Pass 2 W1 deixou trivial:** serialização canônica pronta e congelada em `municipalityListUrl.ts`; acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md) - ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md) +- **B39** lembrar de mim / sessão longa em `/campanha` — pedido de produto (2026-07-26): logout frequente; hoje `DEFAULT_TOKEN_EXPIRATION = 7200` (2 h) no cookie `campaign-token`. Checkbox "Lembrar de mim neste dispositivo" (default off); sem lembrar ≈ **8 h**, com lembrar ≈ **14 d**; JWT + `maxAge` alinhados; sem migration/Consent · ~0,5d · Janela 1 · Impeccable B · soft: B40 (mesmo `LoginForm`) · **não cortável** nesta leva (atrito diário de adoção) · [plano](plans/lembrar-de-mim-sessao-campanha.md) +- **B40** login com biometria (WebAuthn) em `/campanha` — após o 1º login com senha, enrollment opt-in no dispositivo; na próxima visita, platform authenticator (digital/Face ID) emite o mesmo `campaign-token` (TTL longo). Collection/credenciais server-side; senha permanece; migration leve · ~1,5–2d · Janela 1–2 · Impeccable C · soft: **B39** (TTL/copy no login) · cortável · [plano](plans/login-biometrico-campanha.md) +- **B41** scroll horizontal + coluna Município fixa na lista de municípios — em `md+`, a tabela deixa de estourar a viewport (`overflow-x-auto` + `sticky left-0` na coluna nome), espelhando **B21 ✓** `TerritoryList`; revisita o trade-off do **B16 ✓** (`overflow-x-visible` para sticky header) · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B17/B38 ✓ · cortável (depois de B42 na lista de cortes) · [plano](plans/scroll-horizontal-lista-municipios.md) +- **B42** polimento mobile da lista de municípios — Tendência e Assessores abrem **Drawer** no mobile (Popover no desktop), no padrão do **B26 ✓** sinal; card mais baixo (Classe sem prosa, Cobertura `compact`, sem botão "Abrir município", tap no card abre o detalhe) · ~1d · Janela 1 · Impeccable B · soft: B24 ✓/B27 ✓/B26 ✓; paralelo a B41 · cortável · [plano](plans/polimento-mobile-lista-municipios.md) - ~~**E18** documentação de conceitos de inteligência de campanha~~ — **entregue 2026-07-24** (`/campanha/conceitos` staff-only; 7 conceitos de E8 em `src/lib/campaignIntelligenceConcepts.ts` agrupados em base/diagnóstico/meta; "Saiba mais" por métrica nos tooltips + link de teclado no Popover do card; `formatElectionNumber` passou a arredondar votos) · cada fatia futura (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array como parte da própria entrega · [plano](plans/documentacao-conceitos-campanha.md) ### Fill-ins abertos @@ -162,6 +166,10 @@ flowchart TD B8F2["B8 F2 Polígonos
zonas de Salvador"] B14n["B14 ✓ Município mais próximo"] B38n["B38 ✓ Sidebar recolhido
em tablet"] + B39n["B39 Lembrar de mim
/ sessão longa"] + B40n["B40 Login biométrico
WebAuthn"] + B41n["B41 Scroll horizontal
+ sticky Município"] + B42n["B42 Polimento mobile
lista municípios"] B15n["B15 ✓ Ordenar lista
por coluna"] B16n["B16 ✓ Filtros no header
da lista"] B17n["B17 Seletor de colunas
da lista"] @@ -218,6 +226,12 @@ flowchart TD B16n -.barra slim.-> B18n B17n -.vizinho barra.-> B18n B38n -.rail aberto p/ submenu.-> B18n + B39n -.TTL / LoginForm.-> B40n + B16n -.trade-off overflow-x.-> B41n + B38n -.viewport tablet.-> B41n + B26n -.Drawer mobile.-> B42n + B24n -.Trend control.-> B42n + B27n -.Advisors control.-> B42n B16n -.densidade do th.-> B22n E18n -.oneLiner dos conceitos.-> B22n B22n -.colunas se explicam.-> B17n @@ -309,11 +323,11 @@ flowchart TD B8F2 -.ZE Salvador.-> B14n ``` -Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; B8 F2 segue como gatilho só para desambiguar as ZE de Salvador), ~~**B38**~~ ✓ (sidebar recolhido em tablet entregue 2026-07-26), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). +Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; B8 F2 segue como gatilho só para desambiguar as ZE de Salvador), ~~**B38**~~ ✓ (sidebar recolhido em tablet entregue 2026-07-26), **B39** (lembrar de mim / sessão longa; sem dep dura — cookie/JWT já existem; soft B40 só pela ordem no mesmo `LoginForm`), **B40** (login biométrico; soft B39 pelo TTL/copy — pode começar depois do B39), **B41** (scroll horizontal + sticky na lista de municípios; soft B16 ✓/B38 ✓ — não bloqueia), **B42** (polimento mobile da mesma lista; soft B26 ✓/B24 ✓/B27 ✓ — paralelo a B41), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **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`); **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 — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **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) se folga de UX da lista no onboarding; 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 **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):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **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 — cortável); **B41** (scroll horizontal + sticky na lista de municípios — tabela estoura tablet/desktop; ~0,5–0,75 dia) e **B42** (polimento mobile da mesma lista — Drawer em Tendência/Assessores + card compacto; ~1 dia, paralelo a B41); **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 — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **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) se folga de UX da lista no onboarding; 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 **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 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. @@ -323,9 +337,9 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun ### Cortes seguros / não cortáveis -**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`). +**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`); **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): **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~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **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~~ (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 (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~~ (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); ~~**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~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B40** login biométrico (senha + B39 continuam — cortar custa só o atalho de digital/Face ID; 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 (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 (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~~ (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 (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~~ (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); ~~**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