Expand the compiled next-steps doc back into the full delivery history, cross-reference plan statuses, and sync AGENTS.md, PRODUCT.md, and the Núcleos project notebook.
14 KiB
Visitados recentemente
Status: implementado Atualizado em: 2026-07-19 Item do roadmap: docs/roadmap.md (seção "Campanha → Próximos ciclos") Responsável: —
Contexto
Hoje o dashboard /campanha (Início) mostra agregados globais e o feed de atualizações, mas não oferece um atalho para os lugares que o usuário acabou de visitar. Um coordenador que volta da ficha de um núcleo ou de uma listagem filtrada precisa refazer a navegação manualmente — reabrir Núcleos, reaplicar o filtro, reencontrar o núcleo. A decisão de produto (2026-07-17) é adicionar uma seção "Visitados recentemente" que lista os últimos lugares acessados dentro de /campanha: um núcleo específico (/campanha/nucleos/[slug]) ou uma listagem de núcleos com filtro específico (/campanha/nucleos?...).
O requisito explícito é não criar um modelo complexo: nada de collection no servidor, migration, server action de escrita ou Consent. O histórico é mantido no próprio navegador do usuário.
Realidade técnica do "histórico do browser"
A window.history não expõe a lista de entradas (URLs) da stack por motivo de privacidade — só history.length (um número) e history.state (estado da entrada atual). Não é possível enumerar o back/forward. Portanto "usar o histórico do browser" é interpretado como: registrar a navegação do usuário client-side, em localStorage, conforme ele acessa páginas "interessantes" da campanha. Isso cumpre o objetivo (nenhum modelo no servidor) e deriva da navegação real no navegador do usuário. A limitação (por dispositivo, não sincronizado entre dispositivos/navegadores) é aceita como trade-off e flagada abaixo.
Objetivos
- Painel "Visitados recentemente" no dashboard
/campanha, listando os últimos lugares acessados como links clicáveis. - Registrar automaticamente, sem ação do usuário, visitas a:
- núcleo específico (
/campanha/nucleos/[slug]) — label = nome do núcleo; - listagem de núcleos com filtro específico (
/campanha/nucleos?...com ao menos um filtro ativo) — label derivada do estado de filtro.
- núcleo específico (
- Histórico client-side em
localStorage: sem collection, sem migration, sem server action, semConsent, sem escrita no servidor. - Deduplicação por URL (pathname + search): revisitar o mesmo lugar atualiza o timestamp e sobe para o topo, sem duplicar.
- Tempo mínimo de permanência (dwell) antes de gravar, para não registrar páginas intermediárias enquanto o usuário monta um filtro.
- Lista limitada a 8 entradas, ordenadas da mais recente à mais antiga.
- Hidratação segura:
localStorageé browser-only, então a lista só é renderizada após montagem no cliente (sem mismatch de hidratação).
Decisões travadas
- Sem modelo no servidor. Nenhuma collection, nenhum campo em
campaignUser, nenhuma migration, nenhuma server action de escrita, nenhumConsent. Todo o estado vive emlocalStorageno dispositivo do usuário. (Decisão de produto 2026-07-17; roadmap "Campanha → Próximos ciclos".) localStorage, nãosessionStorage. A intenção é "voltar aonde eu estava" entre sessões;sessionStoragemorre ao fechar a aba.localStoragepersiste por origem+dispositivo até limpeza. Chave estável:teqo:campaign:recent-visits.- Por dispositivo, sem sync. O histórico é local ao navegador. Não há sincronização entre dispositivos do mesmo usuário (isso exigiria modelo no servidor — fora de escopo). Aceito como trade-off do "sem modelo".
- Registro client-side derivado da navegação real. Como
window.historynão enumera entradas, um componente cliente invisível (RecentVisitTracker) registra cada visita "interessante" nolocalStorage. A entrada é produzida no servidor (label) e gravada no cliente (storage) — sem round-trip de escrita. - O que conta como "lugar". Apenas: (a) detalhe de núcleo e (b) listagem de núcleos com filtro ativo. A listagem sem filtro (
/campanha/nucleospuro) e o próprio dashboard (/campanha) não são registrados — são sempre um clique de distância e inundariam a lista. Outras rotas (convite público, login) ficam fora. - Dedup por
href(pathname + search normalizado). Revisitar atualizavisitedAte reposiciona no topo. Filtros equivalentes com ordem de querystring diferente são considerados o mesmo lugar após normalização (reusarresolveNucleusListUrl/NucleusListStatepara canonicalizar). - Tempo mínimo de permanência (dwell) antes de registrar. A visita só é gravada depois que o usuário permanece naquela URL por um tempo mínimo (default 2000 ms). Enquanto o usuário monta um filtro, a listagem atualiza a URL com
router.replacesem remontar a página — cada mudança desearchParamsmuda ohrefe re-arma o timer, descartando os estados intermediários; só o filtro final, em que o usuário repousou pelo dwell, é registrado. Se o usuário sai antes do dwell, nada é gravado. Isso evita o acúmulo de páginas intermediárias e é o motivo de o tracker ser por-hrefcom timer, e não gravação imediata no mount. (Decisão de produto 2026-07-17.) - Lista limitada a 8 entradas. Suficiente para "voltar aonde estava" sem virar histórico completo. (
MAX_ENTRIES = 8.) - Access control herdado, sem validação no render. Entradas são só links para rotas autenticadas; a rota de destino continua aplicando seu escopo (
canReadElectoralNucleusetc.). Se o usuário perdeu acesso a um núcleo (ex.: liderança desengajada), o link pode continuar aparecendo no histórico local, mas o destino negará acesso. Não há limpeza reativa — best-effort. (Ver "Questões em aberto".) - Sem
Consent. É o histórico de navegação do próprio usuário no próprio dispositivo, análogo ao histórico do browser — não há dado de terceiros nem transmissão ao servidor. Não configura tratamento de PII de terceiros sob LGPD. Se futuramente houver sync servidor, aí sim vira modelo +Consent(fora de escopo). - i18n e naming seguem o AGENTS.md: identificadores em inglês (
RecentVisitTracker,RecentlyVisited,recordRecentVisit,listRecentVisits,buildNucleusListVisitLabel), strings visíveis em pt-BR.
Questões em aberto
Resolvidas na implementação (2026-07-19):
- Registrar listagem só com filtro ativo? Sim —
buildNucleusListVisitLabelretornanullsem filtros;q=conta; sópagenão conta. - Valor exato do dwell. 2000 ms (
RECORD_DWELL_MSemrecentVisits.ts). - Formato da label do filtro.
Núcleos · {região} · {município} · Zona N · {cobertura} · {estimativa} · Busca "…", truncada em 80 caracteres. - Onde mais mostrar, além do dashboard? Só no dashboard
/campanha(três variantes por role). - Limpeza de entradas obsoletas. Best-effort; botão "Limpar" no painel.
- Dispositivo compartilhado. Aceito; histórico limpo no logout.
- Limpar ao deslogar? Sim —
clearRecentVisits()emCampaignSidebar.handleLogout(client-side, junto comclearCampaignPwaCaches). - PWA/offline. Painel lê
localStorage; funciona offline quando o PWA estiver ativo.
Abordagem proposta
flowchart LR
Sub["Páginas interessantes (server)"]
Detail["Detalhe do núcleo<br/>entry: {href, label=name, kind=nucleus}"]
List["Listagem com filtro<br/>buildNucleusListVisitLabel(state)<br/>entry: {href, label, kind=nucleusList}"]
Tracker["RecentVisitTracker (client)<br/>invisível · on mount"]
Store["recentVisits.ts (client-only)<br/>localStorage<br/>teqo:campaign:recent-visits"]
Panel["RecentlyVisited (client)<br/>painel no dashboard"]
Links["Links → /campanha/nucleos/...<br/>access control herdado"]
Logout["logoutCampaign<br/>clearRecentVisits()"]
Sub --> Detail
Sub --> List
Detail --> Tracker
List --> Tracker
Tracker --> Store
Store --> Panel
Panel --> Links
Store -.limpa.-> Logout
Componentes:
src/utilities/recentVisits.ts(client-only, guardatypeof window === 'undefined'):STORAGE_KEY = 'teqo:campaign:recent-visits',MAX_ENTRIES = 8.type RecentVisitKind = 'nucleus' | 'nucleusList'.type RecentVisitEntry = { href: string, label: string, kind: RecentVisitKind, visitedAt: number }.recordRecentVisit(entry): lê a lista, remove entrada com mesmohref(dedup), insereentryno topo, trunca emMAX_ENTRIES, grava. No-op fora do browser.listRecentVisits(): RecentVisitEntry[]: lê e parseia; retorna[]se ausente/inválido. No-op fora do browser.clearRecentVisits(): remove a chave. Usado no logout.- Normalização de
hrefantes de gravar/dedupar: paranucleusList, reusar a canonicalização deresolveNucleusListUrl/NucleusListStatepara que filtros equivalentes colidam.
RecentVisitTracker(client, emsrc/components/campaign/, renderizanull):- Props:
{ entry: RecentVisitEntry }. useEffectdependente deentry.href: arma umsetTimeout(RECORD_DWELL_MS)(default 2000). Quando dispara, chamarecordRecentVisit(entry). Seentry.hrefmuda antes do disparo (usuário ainda montando filtro) ou o componente desmonta (saiu da página), o cleanup cancela o timer — nada é gravado. Sem UI.- Renderizado no detalhe do núcleo e na listagem (com filtro ativo).
- Props:
RecentlyVisited(client, emsrc/components/campaign/):useStateinicial[];useEffectno mount popula comlistRecentVisits(). Evita mismatch de hidratação (servidor renderiza vazio, cliente preenche).- Se vazio, renderiza
null(painel some) ou um estado vazio curto — definir com produto (recomendação:null, sem UI órfã). - Caso contrário, painel com título "Visitados recentemente" e lista horizontal/vertical de links (label + ícone por
kind+ data relativa). ReusaCard/Button/íconeslucide-reacte orelativeFormatterdoCampaignDashboard. - Botão "Limpar" chama
clearRecentVisits()e reseta o estado local.
buildNucleusListVisitLabel(state: NucleusListState): string | null(emsrc/utilities/nucleusUi.ts):- Retorna
nullquando nenhum filtro está ativo (listagem nua não é registrada). - Caso contrário, compõe label curta a partir de
state(território + cobertura +q), truncada. Formato exato a definir com produto (ver questões em aberto).
- Retorna
- Integração no dashboard (
src/app/(campaign)/campanha/(app)/page.tsx+CampaignDashboard.tsx): renderizar<RecentlyVisited />como um painel (recomendação: no topo, antes dos agregados, como atalho de "voltar aonde estava"; confirmar com produto). Como é client-only, o servidor renderiza o placeholder vazio e o cliente preenche. - Integração no detalhe do núcleo (
src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx): renderizar<RecentVisitTracker entry={{ href: \/campanha/nucleos/${slug}`, label: nucleus.name, kind: 'nucleus' }} />` (invisível). - Integração na listagem (
src/app/(campaign)/campanha/(app)/nucleos/page.tsx): computarlabel = buildNucleusListVisitLabel(state); selabelnão fornull, renderizar<RecentVisitTracker entry={{ href: canonicalUrl.href, label, kind: 'nucleusList' }} />(invisível). - Logout (
src/components/campaign/CampaignSidebar.tsx):clearRecentVisits()emhandleLogout, junto comclearCampaignPwaCaches()— protege dispositivo compartilhado. - Sem migration, sem collection, sem server action de escrita. Tudo é leitura no servidor + estado client-side.
Dependências
- Nenhuma de outro plano. Reusa
NucleusListState,resolveNucleusListUrl(src/utilities/nucleusUi.ts),CampaignDashboard/relativeFormatter(src/components/campaign/CampaignDashboard.tsx) e UICard/Button(src/components/ui/). - Funciona online desde já; o modo offline depende do item PWA do roadmap (não é bloqueador deste plano).
Não escopo
- Sincronização do histórico entre dispositivos — exigiria modelo no servidor +
Consent, exatamente o que este plano evita. - Histórico de outras rotas além de detalhe de núcleo e listagem filtrada (dashboard, convite público, login).
- Validação reativa de acesso (poda de entradas cujo destino deixou de ser acessível) — best-effort neste ciclo.
- Registro de telemetria/analytics — este é histórico de navegação local do usuário, não rastreamento para a campanha.
- Substituir a navegação principal (sidebar/bottom nav) — o painel é atalho complementar.
Escala e DRY pós-MVP (VR+)
Três passagens /simplify (2026-07-19) aplicaram cleanup pontual no MVP. Débitos maiores que cleanup — refresh confiável após bfcache/multi-tab, unificação de tempo relativo no dashboard, shell compartilhado de linha de lista (QueueList ↔ RecentlyVisited), hoisting do painel no wrapper do dashboard — foram registrados no plano de follow-up escala-dry-pos-visitados-recentemente.md (item VR+ em docs/roadmap.md, fill-ins).
Referências
docs/roadmap.md(fill-ins: Visitados recentemente ✓, VR+)- escala-dry-pos-visitados-recentemente.md — débitos pós-
/simplify src/app/(campaign)/campanha/(app)/page.tsxesrc/components/campaign/CampaignDashboard.tsx— onde o painel entrasrc/app/(campaign)/campanha/(app)/nucleos/page.tsx— listagem (registro de filtro)src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx— detalhe (registro de núcleo)src/utilities/nucleusUi.ts—NucleusListState,resolveNucleusListUrl(canonicalização dohref/label)src/app/(campaign)/campanha/actions/auth.ts—logoutCampaign(server action; limpeza do histórico fica no client)- AGENTS.md — Campaign auth, naming conventions (identificadores em inglês, strings em pt-BR)