Register B39–B42: session persistence, biometrics, and municipality list UX.
This commit is contained in:
parent
f4e7b4f1e0
commit
6fc3bd04b2
6 changed files with 465 additions and 5 deletions
|
|
@ -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).
|
||||
|
|
|
|||
106
docs/plans/lembrar-de-mim-sessao-campanha.md
Normal file
106
docs/plans/lembrar-de-mim-sessao-campanha.md
Normal file
|
|
@ -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<br/>tokenExpiration short|long"]
|
||||
login --> cookie["setCampaignAuthCookie<br/>maxAge = tokenExpiration"]
|
||||
cookie --> gate["getCampaignUser<br/>(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
|
||||
110
docs/plans/login-biometrico-campanha.md
Normal file
110
docs/plans/login-biometrico-campanha.md
Normal file
|
|
@ -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<br/>(B39)"] --> offer["Prompt enrollment<br/>opt-in"]
|
||||
offer --> reg["navigator.credentials.create"]
|
||||
reg --> store["POST /campanha/webauthn/register<br/>salva credential"]
|
||||
loginUI["LoginForm"] --> get["GET challenge"]
|
||||
get --> assert["navigator.credentials.get"]
|
||||
assert --> verify["POST /campanha/webauthn/login"]
|
||||
verify --> cookie["setCampaignAuthCookie<br/>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`
|
||||
125
docs/plans/polimento-mobile-lista-municipios.md
Normal file
125
docs/plans/polimento-mobile-lista-municipios.md
Normal file
|
|
@ -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 `<article>` (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<br/>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`
|
||||
104
docs/plans/scroll-horizontal-lista-municipios.md
Normal file
104
docs/plans/scroll-horizontal-lista-municipios.md
Normal file
|
|
@ -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 `<main>` (`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<br/>vertical"] --> tableWrap["CampaignTable container<br/>overflow-x-auto"]
|
||||
tableWrap --> thName["th/td name<br/>sticky left-0"]
|
||||
tableWrap --> rest["demais colunas<br/>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`
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue