From 51364da29f791d7ec6ac444416d3340aa3a3b1eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 19 Jul 2026 04:06:14 -0300 Subject: [PATCH] Add recent visits panel to /campanha and register VR+ simplify follow-ups. --- .../escala-dry-pos-visitados-recentemente.md | 100 ++++++++++++++++++ docs/plans/visitados-recentemente.md | 33 +++--- docs/roadmap.md | 12 ++- .../campanha/(app)/nucleos/[slug]/page.tsx | 8 ++ .../campanha/(app)/nucleos/page.tsx | 13 +++ src/components/campaign/CampaignDashboard.tsx | 15 ++- src/components/campaign/CampaignSidebar.tsx | 2 + src/components/campaign/NucleusFilters.tsx | 18 +++- .../campaign/RecentVisitTracker.tsx | 27 +++++ src/components/campaign/RecentlyVisited.tsx | 64 +++++++++++ src/utilities/formatRelativeAge.ts | 11 ++ src/utilities/nucleusUi.ts | 37 +++++++ src/utilities/recentVisits.ts | 60 +++++++++++ tests/unit/campaignNucleusUi.unit.spec.ts | 36 +++++++ tests/unit/recentVisits.unit.spec.ts | 67 ++++++++++++ 15 files changed, 478 insertions(+), 25 deletions(-) create mode 100644 docs/plans/escala-dry-pos-visitados-recentemente.md create mode 100644 src/components/campaign/RecentVisitTracker.tsx create mode 100644 src/components/campaign/RecentlyVisited.tsx create mode 100644 src/utilities/formatRelativeAge.ts create mode 100644 src/utilities/recentVisits.ts create mode 100644 tests/unit/recentVisits.unit.spec.ts diff --git a/docs/plans/escala-dry-pos-visitados-recentemente.md b/docs/plans/escala-dry-pos-visitados-recentemente.md new file mode 100644 index 00000000..a6468df3 --- /dev/null +++ b/docs/plans/escala-dry-pos-visitados-recentemente.md @@ -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
pageshow + storage listener"] + F1 --> F2["Fase 2 — DRY tempo relativo
formatRelativeAge variants"] + F2 --> F3["Fase 3 — CampaignLinkListRow
(QueueList + RecentlyVisited)"] + F3 --> F4["Fase 4 — Dashboard shell
(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 + **``** + 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 diff --git a/docs/plans/visitados-recentemente.md b/docs/plans/visitados-recentemente.md index 9270ef15..54a0e04d 100644 --- a/docs/plans/visitados-recentemente.md +++ b/docs/plans/visitados-recentemente.md @@ -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 `` 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 `` (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 `` (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) diff --git a/docs/roadmap.md b/docs/roadmap.md index f7b24dee..82a08d47 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; 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 (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; 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 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; 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 (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; 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. @@ -113,12 +113,13 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) | | 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) | | 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 @@ -218,7 +219,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, 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), C7 (após C3; suave: C6 F2 ✓), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) 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, 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), C7 (após C3; suave: C6 F2 ✓), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) 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 @@ -273,12 +274,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), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), 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), **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 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **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), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), 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), **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 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **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) diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx index ffd69cdf..e4c37ce6 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx @@ -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} /> + ) } diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx index c0242f4c..77d5d877 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx @@ -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 (
@@ -115,6 +119,15 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) { )} + {listVisitLabel ? ( + + ) : null}
) } diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index f178796a..4a36b3f4 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -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 ) -const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => ( +const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: Date }) => (
@@ -178,6 +179,8 @@ const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
+ +

Indicadores da campanha @@ -263,6 +266,8 @@ const CoordinatorDashboard = ({

O que precisa de atenção nos seus núcleos?

+ + {view.cards.length ? ( @@ -313,8 +318,10 @@ const CoordinatorDashboard = ({ const LeadershipDashboard = ({ view, + now, }: { view: Extract + now: Date }) => (
@@ -325,6 +332,8 @@ const LeadershipDashboard = ({

+ + {view.cards.length ? ( @@ -413,7 +422,7 @@ export const CampaignDashboard = ({ view: GeneralDashboardViewModel | ScopedDashboardViewModel now: Date }) => { - if (view.role === 'geral') return + if (view.role === 'geral') return if (view.role === 'coordenador') return - return + return } diff --git a/src/components/campaign/CampaignSidebar.tsx b/src/components/campaign/CampaignSidebar.tsx index c79abda5..245aee91 100644 --- a/src/components/campaign/CampaignSidebar.tsx +++ b/src/components/campaign/CampaignSidebar.tsx @@ -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) => { event.preventDefault() + clearRecentVisits() await clearCampaignPwaCaches() await logoutCampaign() } diff --git a/src/components/campaign/NucleusFilters.tsx b/src/components/campaign/NucleusFilters.tsx index b18c7aac..b29c508a 100644 --- a/src/components/campaign/NucleusFilters.tsx +++ b/src/components/campaign/NucleusFilters.tsx @@ -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]" > Todas - Com coordenador - Sem coordenador + + {nucleusListCoverageLabels.com_coordenador} + + + {nucleusListCoverageLabels.sem_coordenador} + @@ -122,8 +128,12 @@ const FilterFields = ({ className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" > Todas - Confirmada - Sem confirmação + + {nucleusListEstimateLabels.confirmada} + + + {nucleusListEstimateLabels.sem_confirmacao} + diff --git a/src/components/campaign/RecentVisitTracker.tsx b/src/components/campaign/RecentVisitTracker.tsx new file mode 100644 index 00000000..75df52c5 --- /dev/null +++ b/src/components/campaign/RecentVisitTracker.tsx @@ -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 +}) => { + 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 +} diff --git a/src/components/campaign/RecentlyVisited.tsx b/src/components/campaign/RecentlyVisited.tsx new file mode 100644 index 00000000..eb20b60b --- /dev/null +++ b/src/components/campaign/RecentlyVisited.tsx @@ -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([]) + + useEffect(() => { + setEntries(listRecentVisits()) + }, []) + + const handleClear = () => { + clearRecentVisits() + setEntries([]) + } + + if (!entries.length) return null + + const nowMs = now.getTime() + + return ( + + + Visitados recentemente + + + +
    + {entries.map((entry) => { + const Icon = entry.kind === 'nucleus' ? Building2Icon : FilterIcon + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/src/utilities/formatRelativeAge.ts b/src/utilities/formatRelativeAge.ts new file mode 100644 index 00000000..9787c0d1 --- /dev/null +++ b/src/utilities/formatRelativeAge.ts @@ -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') +} diff --git a/src/utilities/nucleusUi.ts b/src/utilities/nucleusUi.ts index e75d9f6b..30654c18 100644 --- a/src/utilities/nucleusUi.ts +++ b/src/utilities/nucleusUi.ts @@ -211,6 +211,43 @@ export const resolveNucleusListUrl = ( totalPages, }) +export const nucleusListCoverageLabels: Record, string> = { + com_coordenador: 'Com coordenador', + sem_coordenador: 'Sem coordenador', +} + +export const nucleusListEstimateLabels: Record, string> = { + confirmada: 'Confirmada', + sem_confirmacao: 'Sem confirmação', +} + +const nucleusListVisitEstimateLabels: Record, 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` diff --git a/src/utilities/recentVisits.ts b/src/utilities/recentVisits.ts new file mode 100644 index 00000000..b388c767 --- /dev/null +++ b/src/utilities/recentVisits.ts @@ -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 + 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. + } +} diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts index aa91f3e2..148b715f 100644 --- a/tests/unit/campaignNucleusUi.unit.spec.ts +++ b/tests/unit/campaignNucleusUi.unit.spec.ts @@ -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®ion=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®ion=Chapada+Diamantina') + }) + it('parses the progressive-enhancement create form into domain input', () => { const formData = new FormData() formData.set('name', 'Núcleo Chapada') diff --git a/tests/unit/recentVisits.unit.spec.ts b/tests/unit/recentVisits.unit.spec.ts new file mode 100644 index 00000000..e6ecc717 --- /dev/null +++ b/tests/unit/recentVisits.unit.spec.ts @@ -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 => ({ + 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() + }) +})