Merge branch 'cursor/667b8d6e': recent visits panel and VR+ follow-ups.

# Conflicts:
#	docs/roadmap.md
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 04:10:57 -03:00
commit 4e68e647d4
15 changed files with 478 additions and 25 deletions

View file

@ -0,0 +1,100 @@
# Escala e DRY pós-visitados recentemente
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (VR+, fill-in de engenharia pós-MVP)
Responsável: —
## Contexto
O fill-in **Visitados recentemente** ([visitados-recentemente.md](visitados-recentemente.md)) entregou o MVP client-side: `recentVisits.ts` + `RecentVisitTracker` + `RecentlyVisited` no dashboard `/campanha`, integração em detalhe/listagem de núcleos, `buildNucleusListVisitLabel` / `buildNucleusListVisitHref`, labels de cobertura/estimativa compartilhados com `NucleusFilters`, e limpeza no logout (`CampaignSidebar`). Três passagens `/simplify` (2026-07-19) aplicaram cleanup pontual; os revisores (quality / performance / reuse) deixaram débitos **maiores que cleanup** — registrados aqui.
**Já resolvido no simplify (não reabrir):** `formatRelativeAge` extraído para `src/utilities/formatRelativeAge.ts` (evita puxar `campaignTime.ts` no client); prop `now` do servidor; remoção de `RECENT_VISITS_CHANGED_EVENT` (morto — painel e tracker não coexistem na mesma rota); `nucleusListCoverageLabels` / `nucleusListEstimateLabels` compartilhados; `buildNucleusListVisitHref` sem `page` (dedup de paginação); `listVisitHref` lazy no branch com filtro.
## Objetivos
- Painel reflete visitas recentes após navegação back/forward (bfcache) e escrita em outra aba do mesmo origin.
- Um único helper de tempo relativo no dashboard (substituir `lastUpdateLabel` / `relativeDateLabel` locais onde fizer sentido).
- Linha de lista do dashboard reutiliza shell compartilhado (`QueueList` ↔ `RecentlyVisited`) sem duplicar markup.
- `RecentlyVisited` montado uma vez no wrapper do dashboard, respeitando `max-width` por role.
- Guardrails: sem collection/migration/Consent; histórico continua client-only; comportamento de dwell/dedup inalterado salvo bugs encontrados.
## Decisões travadas
- **Um plano VR+, quatro fases ordenadas.** Mesmo racional de O0+/B5/C8: um registro no roadmap, PRs por fase. Ordem: refresh confiável → DRY tempo relativo → DRY row shell → DRY layout dashboard.
- **Dependência suave do fill-in Visitados recentemente ✓.** Não bloqueia uso do MVP; melhora UX em back-navigation e consistência visual.
- **Sem sync servidor.** Histórico multi-dispositivo, poda reativa de acesso e histórico de planos/apoiadores permanecem fora de escopo (exigiriam modelo + `Consent`).
- **Cortável:** Fases 3–4 (row shell + layout wrapper) até o dashboard ganhar mais painéis do mesmo padrão; Fase 2 (relative time) é barata e reduz drift — preferir se houver folga.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`useRecentVisits`, `CampaignLinkListRow`, `formatLastUpdateLabel`), strings visíveis em pt-BR.
## Questões em aberto
- **`pageshow` + `storage` vs reintroduzir evento custom?** O evento foi removido porque tracker e painel não montam juntos; bfcache e multi-tab são os casos reais restantes. **Recomendação:** `pageshow` (`event.persisted`) + `window.addEventListener('storage', …)` filtrando `STORAGE_KEY` — sem event bus custom.
- **Unificar `floor` (dias) vs `round` (minutos/horas)?** `lastUpdateLabel` usa dias inteiros; `formatRelativeAge` arredonda minutos/horas/dias. **Recomendação:** exportar variantes explícitas em `formatRelativeAge.ts` (`formatRelativeAgeDays`, `formatRelativeAge`) em vez de forçar um único comportamento.
- **Extrair row shell agora?** Só dois consumidores (`QueueList`, `RecentlyVisited`) com diferenças (ícone leading vs `ArrowRight`, prefixo "Última atualização"). **Recomendação:** extrair quando um terceiro painel de atalhos aparecer, ou na Fase 3 se o drift visual incomodar produto.
- **`next/dynamic` no painel?** **Recomendação:** só se Lighthouse mostrar o chunk como relevante no `/campanha`; o painel já retorna `null` quando vazio.
## Abordagem proposta
```mermaid
flowchart TD
VR["Visitados recentemente MVP ✓"] --> F1
F1["Fase 1 — Refresh confiável<br/>pageshow + storage listener"]
F1 --> F2["Fase 2 — DRY tempo relativo<br/>formatRelativeAge variants"]
F2 --> F3["Fase 3 — CampaignLinkListRow<br/>(QueueList + RecentlyVisited)"]
F3 --> F4["Fase 4 — Dashboard shell<br/>(RecentlyVisited hoisted)"]
```
### Fase 1 — Refresh confiável do painel
- **`RecentlyVisited`** (`src/components/campaign/RecentlyVisited.tsx`): no `useEffect` de mount, registrar `pageshow` (refresh quando `event.persisted`) e `storage` (quando `e.key === STORAGE_KEY` ou `e.key === null` para `clear()`).
- Opcional: extrair `useRecentVisits()` (`useState` + listeners + `listRecentVisits`) se Fase 1 crescer — hoje um hook de ~15 linhas é suficiente.
### Fase 2 — DRY de tempo relativo no dashboard
- **`formatRelativeAge.ts`**: adicionar `formatRelativeAgeInDays(timestampMs, nowMs)` (floor, como `lastUpdateLabel`) além do bucketing atual.
- **`CampaignDashboard.tsx`**: migrar `lastUpdateLabel` para helper compartilhado (manter prefixo "Última atualização" no call site).
- **`NucleusListOverview.tsx`**: migrar `relativeDateLabel` para o mesmo helper (dia-only).
- **`NucleusUpdateFeed.tsx`**: substituir `new Intl.RelativeTimeFormat` por instância do módulo compartilhado (hoje aloca formatter a cada call).
### Fase 3 — DRY de linha de lista
- Extrair **`CampaignLinkListRow`** (ex. `src/components/campaign/CampaignLinkListRow.tsx`): props `href`, `title`, `subtitle?`, `leadingIcon?`, `trailingIcon?` (`ArrowRightIcon` default para filas).
- Refatorar **`QueueList`** em `CampaignDashboard.tsx` e **`RecentlyVisited`** para usar o componente; alinhar classes (`truncate` no subtítulo, `min-h-11`).
### Fase 4 — Shell do dashboard por role
- Introduzir wrapper **`CampaignDashboardShell`** com prop `maxWidthClass` (`max-w-screen-2xl` / `max-w-5xl` / `max-w-4xl`) renderizando header slot + **`<RecentlyVisited now={now} />`** + children.
- `GeneralDashboard` / `CoordinatorDashboard` / `LeadershipDashboard` passam a compor o shell em vez de repetir o painel.
### Fase 5 — Micro-perf (opcional, cortável)
- **`next/dynamic`** com `ssr: false` para `RecentlyVisited` em `CampaignDashboard.tsx` se o bundle do dashboard for gargalo.
- **`recordRecentVisit`**: leitura interna sem re-validação completa no write path (`_readRaw`) — só se profiling mostrar custo (improvável com `MAX_ENTRIES = 8`).
- **`nucleos/page.tsx`**: deduplicar `resolveNucleusListUrl` (pré-existente, fora do core visitados) — registrar junto com A7/C10 se virar padrão de lista.
## Dependências
- **Visitados recentemente MVP ✓** (fill-in entregue em branch local; merge pendente).
- Reusa `formatRelativeAge.ts`, `recentVisits.ts`, `CampaignDashboard.tsx`, `NucleusListOverview.tsx`, `NucleusUpdateFeed.tsx`.
- Sem migration, sem collection, sem `Consent`.
## Não escopo
- Sync multi-dispositivo / histórico no servidor → exigiria collection + `Consent` (permanece no [visitados-recentemente.md](visitados-recentemente.md)).
- Histórico de planos/apoiadores/outras rotas → item separado se produto pedir.
- Poda reativa ao perder acesso (best-effort no MVP) → só se virar confusão recorrente.
- Telemetria/analytics de navegação.
- `buildNucleusDetailHref` — inline `/campanha/nucleos/${slug}` é o padrão do codebase hoje.
## Referências
- [visitados-recentemente.md](visitados-recentemente.md) — MVP entregue
- [escala-dry-pos-onda0.md](escala-dry-pos-onda0.md) — precedente de registro pós-`/simplify`
- `src/utilities/recentVisits.ts`, `src/utilities/formatRelativeAge.ts`
- `src/components/campaign/RecentlyVisited.tsx`, `RecentVisitTracker.tsx`
- `src/components/campaign/CampaignDashboard.tsx` (`QueueList`, `lastUpdateLabel`)
- `src/components/campaign/NucleusListOverview.tsx`, `NucleusUpdateFeed.tsx`
- `src/utilities/nucleusUi.ts` (`buildNucleusListVisitLabel`, `buildNucleusListVisitHref`)
- `docs/roadmap.md` (fill-ins VR+)
- AGENTS.md — naming, sem migration para este item

View file

@ -1,7 +1,7 @@
# Visitados recentemente
Status: rascunho
Atualizado em: 2026-07-17
Status: implementado
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Responsável: —
@ -43,14 +43,16 @@ A `window.history` **não expõe** a lista de entradas (URLs) da stack por motiv
## Questões em aberto
- **Registrar listagem só com filtro ativo?** **Recomendação:** sim — a listagem sem filtro é o destino padrão do menu e não agrega. Definir com produto se faz sentido registrar também buscas (`q=`) isoladas.
- **Valor exato do dwell.** Default proposto: 2000 ms. Muito curto registra estados intermediários; muito longo perde visitas rápidas legítimas (ex.: abrir um núcleo só para checar um dado). **Recomendação:** 2000 ms como ponto de partida, ajustar com uso. Definir com produto.
- **Formato da label do filtro.** Ex.: `Núcleos · Região X`, `Núcleos · Cidade Y · Com estimativa pendente`, `Núcleos · Busca "solla"`. **Recomendação:** label composta curta a partir do `NucleusListState` (território + cobertura + busca), truncada. Definir os rótulos exatos com produto.
- **Onde mais mostrar, além do dashboard?** **Recomendação:** só no dashboard `/campanha` neste ciclo (é o "Início", landing natural). Repetir no topo da listagem de núcleos é opcional e pode poluir — deixar para depois.
- **Limpeza de entradas obsoletas.** Hoje best-effort (link existe, destino pode negar). **Recomendação:** não limpar reativamente neste ciclo; o usuário pode limpar manualmente via botão "Limpar" no painel. Se virar confusão, adicionar poda ao clicar em entrada que resulta em acesso negado.
- **Dispositivo compartilhado.** `localStorage` é por origem; em dispositivo compartilhado, um usuário vê histórico de outro (mesma origem). Não há PII de terceiros, mas nomes de núcleo são internos da campanha. **Recomendação:** aceitar (mesma superfície de qualquer app web nesta origem); se produto exigir isolamento, limpar ao `logoutCampaign` (já é client-side e barato).
- **Limpar ao deslogar?** **Recomendação:** sim — chamar `clearRecentVisits()` em `logoutCampaign` para não vazar histórico local na troca de usuário em dispositivo compartilhado. Confirmar com produto.
- **PWA/offline.** O painel lê `localStorage`, então funciona offline (depende do item PWA do roadmap estar em pé para o modo offline; sem PWA, funciona online normalmente). Sem dependência dura neste plano.
Resolvidas na implementação (2026-07-19):
- **Registrar listagem só com filtro ativo?** Sim — `buildNucleusListVisitLabel` retorna `null` sem filtros; `q=` conta; só `page` não conta.
- **Valor exato do dwell.** 2000 ms (`RECORD_DWELL_MS` em `recentVisits.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()` em `CampaignSidebar.handleLogout` (client-side, junto com `clearCampaignPwaCaches`).
- **PWA/offline.** Painel lê `localStorage`; funciona offline quando o PWA estiver ativo.
## Abordagem proposta
@ -100,7 +102,7 @@ Componentes:
- **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`): computar `label = buildNucleusListVisitLabel(state)`; se `label` não for `null`, renderizar `<RecentVisitTracker entry={{ href: canonicalUrl.href, label, kind: 'nucleusList' }} />` (invisível).
- **Logout** (`src/app/(campaign)/campanha/actions/auth.ts`): chamar `clearRecentVisits()` em `logoutCampaign` (se confirmado) — protege dispositivo compartilhado.
- **Logout** ([`src/components/campaign/CampaignSidebar.tsx`](src/components/campaign/CampaignSidebar.tsx)): `clearRecentVisits()` em `handleLogout`, junto com `clearCampaignPwaCaches()` — protege dispositivo compartilhado.
- **Sem migration, sem collection, sem server action de escrita.** Tudo é leitura no servidor + estado client-side.
## Dependências
@ -116,12 +118,17 @@ Componentes:
- 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](escala-dry-pos-visitados-recentemente.md)** (item **VR+** em [docs/roadmap.md](../roadmap.md), fill-ins).
## Referências
- `docs/roadmap.md` (seção "Campanha → Próximos ciclos", item "Visitados recentemente")
- `docs/roadmap.md` (fill-ins: Visitados recentemente ✓, VR+)
- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — débitos pós-`/simplify`
- `src/app/(campaign)/campanha/(app)/page.tsx` e `src/components/campaign/CampaignDashboard.tsx` — onde o painel entra
- `src/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 do `href`/label)
- `src/app/(campaign)/campanha/actions/auth.ts` — `logoutCampaign` (limpeza opcional)
- `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)

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md))
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -115,12 +115,13 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) |
| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) |
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
**Sem design nesta leva** (encomendar na próxima rodada ou desenhar na implementação): A6 dobradinha 2026, B2/B3/B4 mapa da Bahia, C4 demandas, E1–E4 mapa de projeção por município (seguir os padrões de `Baseline-Eleitoral-2022` e `Lista-Nucleos-Overview`), fill-ins (visitados recentemente, listas globais, reset de senha). O detalhe do plano de ação (tabs Visão geral/Tarefas/Atualizações) e a tela de opt-in de push também não vieram — os planos correspondentes indicam o padrão a seguir.
**Sem design nesta leva** (encomendar na próxima rodada ou desenhar na implementação): A6 dobradinha 2026, B2/B3/B4 mapa da Bahia, C4 demandas, E1–E4 mapa de projeção por município (seguir os padrões de `Baseline-Eleitoral-2022` e `Lista-Nucleos-Overview`), fill-ins (listas globais, reset de senha). O detalhe do plano de ação (tabs Visão geral/Tarefas/Atualizações) e a tela de opt-in de push também não vieram — os planos correspondentes indicam o padrão a seguir.
### Grafo de dependências
@ -223,7 +224,7 @@ flowchart TD
A7 -.mesmo loader do baseline.-> E2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
### Sequência de execução por janela do calendário
@ -279,12 +280,13 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
- **O0+ Escala e DRY pós-Onda 0** (revalidate globals, chaves Consent, testes SQL provision, DRY Lexical/layout) — engenharia pós-MVP; paralelizável com smoke Onda 0. → [detalhes](plans/escala-dry-pos-onda0.md)
- **Visitados recentemente** (client-side, `localStorage`). → [detalhes](plans/visitados-recentemente.md)
- **Visitados recentemente** ✓ (client-side, `localStorage`; MVP entregue 2026-07-19). → [detalhes](plans/visitados-recentemente.md)
- **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md)
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -17,6 +17,7 @@ import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssi
import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog'
import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab'
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard'
@ -213,6 +214,13 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu
view={view}
/>
</Suspense>
<RecentVisitTracker
entry={{
href: `/campanha/nucleos/${view.slug}`,
label: view.name,
kind: 'nucleus',
}}
/>
</div>
)
}

View file

@ -10,6 +10,7 @@ import { CampaignListPagination } from '@/components/campaign/CampaignListPagina
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
import { NucleusList } from '@/components/campaign/NucleusList'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { Button } from '@/components/ui/button'
import {
Empty,
@ -25,6 +26,8 @@ import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
import {
buildNucleusFiltersKey,
buildNucleusListHref,
buildNucleusListVisitHref,
buildNucleusListVisitLabel,
getCampaignScopeLabel,
resolveNucleusListUrl,
} from '@/utilities/nucleusUi'
@ -52,6 +55,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const listVisitLabel = buildNucleusListVisitLabel(state)
return (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
@ -115,6 +119,15 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
</EmptyContent>
</Empty>
)}
{listVisitLabel ? (
<RecentVisitTracker
entry={{
href: buildNucleusListVisitHref(state),
label: listVisitLabel,
kind: 'nucleusList',
}}
/>
) : null}
</div>
)
}

View file

@ -10,6 +10,7 @@ import Link from 'next/link'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { NucleusCard } from '@/components/campaign/NucleusCard'
import { RecentlyVisited } from '@/components/campaign/RecentlyVisited'
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { Badge } from '@/components/ui/Badge'
@ -160,7 +161,7 @@ const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRe
</Card>
)
const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: Date }) => (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
@ -178,6 +179,8 @@ const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
</Button>
</header>
<RecentlyVisited now={now} />
<section aria-labelledby="campaign-kpis">
<h2 id="campaign-kpis" className="sr-only">
Indicadores da campanha
@ -263,6 +266,8 @@ const CoordinatorDashboard = ({
<p className="text-muted-foreground">O que precisa de atenção nos seus núcleos?</p>
</header>
<RecentlyVisited now={now} />
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
{view.cards.length ? (
@ -313,8 +318,10 @@ const CoordinatorDashboard = ({
const LeadershipDashboard = ({
view,
now,
}: {
view: Extract<ScopedDashboardViewModel, { role: 'lideranca' }>
now: Date
}) => (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
@ -325,6 +332,8 @@ const LeadershipDashboard = ({
</p>
</header>
<RecentlyVisited now={now} />
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
{view.cards.length ? (
@ -413,7 +422,7 @@ export const CampaignDashboard = ({
view: GeneralDashboardViewModel | ScopedDashboardViewModel
now: Date
}) => {
if (view.role === 'geral') return <GeneralDashboard view={view} />
if (view.role === 'geral') return <GeneralDashboard view={view} now={now} />
if (view.role === 'coordenador') return <CoordinatorDashboard view={view} now={now} />
return <LeadershipDashboard view={view} />
return <LeadershipDashboard view={view} now={now} />
}

View file

@ -23,6 +23,7 @@ import {
} from '@/components/ui/Sidebar'
import type { CampaignUser } from '@/payload-types'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import { clearRecentVisits } from '@/utilities/recentVisits'
export type CampaignSidebarUser = {
name: string
@ -42,6 +43,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const handleLogout = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
clearRecentVisits()
await clearCampaignPwaCaches()
await logoutCampaign()
}

View file

@ -14,6 +14,8 @@ import { territoryForCity } from '@/lib/bahiaTerritories'
import { cn } from '@/lib/utils'
import {
buildNucleusListSearchParams,
nucleusListCoverageLabels,
nucleusListEstimateLabels,
type NucleusListState,
} from '@/utilities/nucleusUi'
import {
@ -108,8 +110,12 @@ const FilterFields = ({
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
<NativeSelectOption value="com_coordenador">Com coordenador</NativeSelectOption>
<NativeSelectOption value="sem_coordenador">Sem coordenador</NativeSelectOption>
<NativeSelectOption value="com_coordenador">
{nucleusListCoverageLabels.com_coordenador}
</NativeSelectOption>
<NativeSelectOption value="sem_coordenador">
{nucleusListCoverageLabels.sem_coordenador}
</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
@ -122,8 +128,12 @@ const FilterFields = ({
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
<NativeSelectOption value="confirmada">Confirmada</NativeSelectOption>
<NativeSelectOption value="sem_confirmacao">Sem confirmação</NativeSelectOption>
<NativeSelectOption value="confirmada">
{nucleusListEstimateLabels.confirmada}
</NativeSelectOption>
<NativeSelectOption value="sem_confirmacao">
{nucleusListEstimateLabels.sem_confirmacao}
</NativeSelectOption>
</NativeSelect>
</Field>
</form>

View file

@ -0,0 +1,27 @@
'use client'
import { useEffect } from 'react'
import {
RECORD_DWELL_MS,
recordRecentVisit,
type RecentVisitEntry,
} from '@/utilities/recentVisits'
export const RecentVisitTracker = ({
entry,
}: {
entry: Omit<RecentVisitEntry, 'visitedAt'>
}) => {
useEffect(() => {
const timer = window.setTimeout(() => {
recordRecentVisit({ ...entry, visitedAt: Date.now() })
}, RECORD_DWELL_MS)
return () => {
window.clearTimeout(timer)
}
}, [entry.href, entry.label, entry.kind])
return null
}

View file

@ -0,0 +1,64 @@
'use client'
import { Building2Icon, FilterIcon } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
import {
clearRecentVisits,
listRecentVisits,
type RecentVisitEntry,
} from '@/utilities/recentVisits'
export const RecentlyVisited = ({ now }: { now: Date }) => {
const [entries, setEntries] = useState<RecentVisitEntry[]>([])
useEffect(() => {
setEntries(listRecentVisits())
}, [])
const handleClear = () => {
clearRecentVisits()
setEntries([])
}
if (!entries.length) return null
const nowMs = now.getTime()
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Visitados recentemente</CardTitle>
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleClear}>
Limpar
</Button>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-1">
{entries.map((entry) => {
const Icon = entry.kind === 'nucleus' ? Building2Icon : FilterIcon
return (
<li key={entry.href}>
<Button asChild variant="ghost" className="h-auto min-h-11 w-full justify-start gap-2">
<Link href={entry.href}>
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-left">
<strong className="block truncate">{entry.label}</strong>
<span className="block truncate text-xs text-muted-foreground">
{formatRelativeAge(entry.visitedAt, nowMs)}
</span>
</span>
</Link>
</Button>
</li>
)
})}
</ul>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,11 @@
const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
export const formatRelativeAge = (timestampMs: number, nowMs: number): string => {
const diffMs = timestampMs - nowMs
const minutes = Math.round(diffMs / (60 * 1000))
if (Math.abs(minutes) < 60) return relativeFormatter.format(minutes, 'minute')
const hours = Math.round(diffMs / (60 * 60 * 1000))
if (Math.abs(hours) < 24) return relativeFormatter.format(hours, 'hour')
const days = Math.round(diffMs / (24 * 60 * 60 * 1000))
return relativeFormatter.format(days, 'day')
}

View file

@ -211,6 +211,43 @@ export const resolveNucleusListUrl = (
totalPages,
})
export const nucleusListCoverageLabels: Record<NonNullable<NucleusListState['coverage']>, string> = {
com_coordenador: 'Com coordenador',
sem_coordenador: 'Sem coordenador',
}
export const nucleusListEstimateLabels: Record<NonNullable<NucleusListState['estimate']>, string> = {
confirmada: 'Confirmada',
sem_confirmacao: 'Sem confirmação',
}
const nucleusListVisitEstimateLabels: Record<NonNullable<NucleusListState['estimate']>, string> = {
...nucleusListEstimateLabels,
confirmada: 'Estimativa confirmada',
}
const MAX_NUCLEUS_LIST_VISIT_LABEL_LENGTH = 80
export const buildNucleusListVisitLabel = (state: NucleusListState): string | null => {
const parts: string[] = []
if (state.region) parts.push(state.region)
if (state.city) parts.push(state.city)
if (state.tseZone) parts.push(`Zona ${state.tseZone}`)
if (state.coverage) parts.push(nucleusListCoverageLabels[state.coverage])
if (state.estimate) parts.push(nucleusListVisitEstimateLabels[state.estimate])
if (state.q) parts.push(`Busca "${state.q}"`)
if (!parts.length) return null
const label = `Núcleos · ${parts.join(' · ')}`
if (label.length <= MAX_NUCLEUS_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_NUCLEUS_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
export const buildNucleusListVisitHref = (state: NucleusListState): string =>
buildNucleusListHref(state, 1)
export const getCampaignScopeLabel = (role: CampaignUser['role'], nucleusCount: number): string => {
if (role === 'coordenador') {
return `${nucleusCount} ${nucleusCount === 1 ? 'núcleo' : 'núcleos'} sob sua coordenação`

View file

@ -0,0 +1,60 @@
export const STORAGE_KEY = 'teqo:campaign:recent-visits'
export const MAX_ENTRIES = 8
export const RECORD_DWELL_MS = 2000
export type RecentVisitKind = 'nucleus' | 'nucleusList'
export type RecentVisitEntry = {
href: string
label: string
kind: RecentVisitKind
visitedAt: number
}
const isRecentVisitEntry = (value: unknown): value is RecentVisitEntry => {
if (!value || typeof value !== 'object') return false
const entry = value as Record<string, unknown>
return (
typeof entry.href === 'string' &&
entry.href.length > 0 &&
typeof entry.label === 'string' &&
entry.label.length > 0 &&
(entry.kind === 'nucleus' || entry.kind === 'nucleusList') &&
typeof entry.visitedAt === 'number' &&
Number.isFinite(entry.visitedAt)
)
}
export const listRecentVisits = (): RecentVisitEntry[] => {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isRecentVisitEntry)
} catch {
return []
}
}
export const recordRecentVisit = (entry: RecentVisitEntry): void => {
if (typeof window === 'undefined') return
try {
const existing = listRecentVisits()
const deduped = existing.filter((item) => item.href !== entry.href)
const next = [entry, ...deduped].slice(0, MAX_ENTRIES)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// Ignore quota / private-mode failures.
}
}
export const clearRecentVisits = (): void => {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// Ignore private-mode failures.
}
}

View file

@ -14,6 +14,8 @@ import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/c
import {
buildNucleusListHref,
buildNucleusListWhere,
buildNucleusListVisitHref,
buildNucleusListVisitLabel,
getCampaignScopeLabel,
parseNucleusListParams,
} from '@/utilities/nucleusUi'
@ -304,6 +306,40 @@ describe('campaign nucleus UI contracts', () => {
).toBe('/campanha/nucleos?q=Chapada&region=Chapada+Diamantina&page=3')
})
it('builds recent-visit labels only when list filters are active', () => {
expect(buildNucleusListVisitLabel({ page: 1 })).toBeNull()
expect(buildNucleusListVisitLabel({ page: 3 })).toBeNull()
expect(
buildNucleusListVisitLabel({
page: 1,
region: 'Chapada Diamantina',
city: 'Seabra',
coverage: 'sem_coordenador',
}),
).toBe('Núcleos · Chapada Diamantina · Seabra · Sem coordenador')
expect(buildNucleusListVisitLabel({ page: 1, q: 'solla' })).toBe('Núcleos · Busca "solla"')
const label = buildNucleusListVisitLabel({
page: 1,
region: 'Recôncavo',
city: 'Cruz das Almas',
q: 'associação comunitária quilombola do recôncavo baiano',
})
expect(label).not.toBeNull()
expect(label!.length).toBeLessThanOrEqual(80)
expect(label!.endsWith('…')).toBe(true)
})
it('builds recent-visit href without page so filtered pages dedupe', () => {
expect(
buildNucleusListVisitHref({
page: 3,
region: 'Chapada Diamantina',
q: 'Chapada',
}),
).toBe('/campanha/nucleos?q=Chapada&region=Chapada+Diamantina')
})
it('parses the progressive-enhancement create form into domain input', () => {
const formData = new FormData()
formData.set('name', 'Núcleo Chapada')

View file

@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
clearRecentVisits,
listRecentVisits,
MAX_ENTRIES,
recordRecentVisit,
STORAGE_KEY,
type RecentVisitEntry,
} from '@/utilities/recentVisits'
const sampleEntry = (overrides: Partial<RecentVisitEntry> = {}): RecentVisitEntry => ({
href: '/campanha/nucleos/nucleo-chapada',
label: 'Núcleo Chapada',
kind: 'nucleus',
visitedAt: 1_700_000_000_000,
...overrides,
})
describe('recentVisits storage', () => {
afterEach(() => {
localStorage.clear()
})
it('returns an empty list when storage is missing or invalid', () => {
expect(listRecentVisits()).toEqual([])
localStorage.setItem(STORAGE_KEY, 'not-json')
expect(listRecentVisits()).toEqual([])
localStorage.setItem(STORAGE_KEY, JSON.stringify({ href: '/campanha/nucleos/x' }))
expect(listRecentVisits()).toEqual([])
})
it('deduplicates by href and keeps the newest visit at the top', () => {
recordRecentVisit(sampleEntry({ href: '/campanha/nucleos/a', visitedAt: 10 }))
recordRecentVisit(sampleEntry({ href: '/campanha/nucleos/b', visitedAt: 20 }))
recordRecentVisit(sampleEntry({ href: '/campanha/nucleos/a', label: 'Atualizado', visitedAt: 30 }))
expect(listRecentVisits()).toEqual([
sampleEntry({ href: '/campanha/nucleos/a', label: 'Atualizado', visitedAt: 30 }),
sampleEntry({ href: '/campanha/nucleos/b', visitedAt: 20 }),
])
})
it('truncates the list to MAX_ENTRIES', () => {
for (let index = 0; index < MAX_ENTRIES + 2; index += 1) {
recordRecentVisit(
sampleEntry({
href: `/campanha/nucleos/nucleo-${index}`,
label: `Núcleo ${index}`,
visitedAt: index,
}),
)
}
const visits = listRecentVisits()
expect(visits).toHaveLength(MAX_ENTRIES)
expect(visits[0]?.href).toBe(`/campanha/nucleos/nucleo-${MAX_ENTRIES + 1}`)
expect(visits.at(-1)?.href).toBe('/campanha/nucleos/nucleo-2')
})
it('clears stored visits', () => {
recordRecentVisit(sampleEntry())
clearRecentVisits()
expect(listRecentVisits()).toEqual([])
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
})