From dff3a9e8b1bd4660d4d844c711d03f265bdc6b31 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 12:34:04 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(C95):=20seletor=20de=20modo=20de=20vis?= =?UTF-8?q?ualiza=C3=A7=C3=A3o=20no=20header=20do=20app=20(agenda)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - view=week|day|month|list no contrato URL da agenda (canônico, fail-closed) - seletor compacto 'Semana ▾' no header desktop e mobile (slot C94/C95) - FullCalendar headerToolbar sem os 4 botões de modo; escolha vence resize - effect normaliza a vista também quando a URL perde o param (sem vista órfã) - useNarrowMeasured(breakpoint) em hooks/use-mobile (settle de hidratação) - e2e reescrito no seletor + persistência + escolha-vence-resize + estabilização dos waits de slot (grid lazy do FullCalendar substitui gridcells no load) --- ...5-agenda-seletor-modo-visualizacao-impl.md | 120 ++++++++++++++++++ .../(campaign)/campanha/(app)/agenda/page.tsx | 4 + .../campaign/activity/ActivityAgenda.tsx | 31 ++++- .../activity/ActivityAgendaFilters.tsx | 7 +- .../campaign/activity/AgendaViewChrome.tsx | 19 +++ .../campaign/activity/AgendaViewSelector.tsx | 120 ++++++++++++++++++ .../campaign/shell/CampaignMobileTopBar.tsx | 11 +- src/hooks/use-mobile.ts | 28 ++-- src/utilities/activityUi.ts | 35 ++++- tests/e2e/campaignActivity.e2e.spec.ts | 105 +++++++++++++-- tests/unit/activityUi.unit.spec.ts | 71 +++++++++++ 11 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md create mode 100644 src/components/campaign/activity/AgendaViewChrome.tsx create mode 100644 src/components/campaign/activity/AgendaViewSelector.tsx diff --git a/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md b/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md new file mode 100644 index 00000000..c7081c58 --- /dev/null +++ b/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md @@ -0,0 +1,120 @@ +# Impl: C95 — Agenda: seletor de modo de visualização no header do app + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #439 +Intenção: docs/plans/c95-agenda-seletor-modo-visualizacao.md +Appetite restante: ~0,5–1 dia eng (herdado); sem ajuste + +## Leitura da intenção + +- **Outcome:** um seletor único de modo de visualização (Dia/Semana/Mês/Lista) no **header do app** — `CampaignDesktopHeader` no desktop, `CampaignMobileTopBar` no mobile — agenda-contextual (só `/campanha/agenda`), compacto (não cresce `min-h-11`/`min-h-14`), substituindo os 4 botões de modo do FullCalendar (`headerToolbar.end`). A escolha persiste por sessão/reload (estado da tela, junto do filtro — que já vive na URL), respeita o recorte do filtro e **vence** a lógica responsiva de resize (semana↔dia) que hoje existe. +- **O que NÃO negociar:** navegação prev/next/"Hoje" permanece no FullCalendar; criação inline (C91) intacta; leader lockdown intacto; demais telas `/campanha` sem o seletor; header sem segunda linha e sem aumento de altura; escolha do usuário não é trocada por resize. +- **O que reavaliar (hipóteses da intenção):** + - "estado de vista eventualmente junto ao `ActivityAgendaState`/URL" → **confirmado**: `view` entra no contrato URL de `ActivityAgendaState` (mesmo mecanismo dos filtros — canônico, fail-closed, share/reload consistentes). + - "conectar o seletor ao `changeView` via `calendarRef`" → **via URL**: o seletor navega (`useCampaignListFilterNavigation`), o `ActivityAgenda` reage ao `state.view` com um effect que chama `changeView` — sem ref compartilhado entre header e página. + - A lógica responsiva de resize precisa reconciliar com a escolha do usuário — **gate simples**: com `view` na URL, o auto-switch semana↔dia desliga; sem `view`, comportamento atual permanece. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph AGENDA_PAGE[AGENDA PAGE · server] + S[searchParams] --> R[resolveActivityAgendaUrl + restrict → state com view?] + end + R -->|state| VC[AgendaViewChrome · client] + VC -->|registra| H[SetCampaignHeaderAction id=agenda-view] + H -->|lê| DH[CampaignDesktopHeader · cluster direito] + H -->|lê| MB[CampaignMobileTopBar · junto ao sino] + VC -->|opções navegam| NAV[useCampaignListFilterNavigation → buildActivityAgendaHref] + NAV -->|router.replace view=X| R + R -->|novo state.view| AG[ActivityAgenda] + AG -->|effect state.view → changeView| FC[FullCalendar · headerToolbar sem end] +``` + +**Opções consideradas (estado da vista):** A) URL param `view` no `ActivityAgendaState` | B) contexto React compartilhado header↔página | C) localStorage/sessionStorage + estado local. +**Recomendação:** A — "edit the owner": o filtro da agenda já é estado de tela na URL (`municipality`/`deputyPresent`/`tag`); a vista é a mesma classe de estado. Ganha canonicalização/redirect fail-closed de graça (`resolveActivityAgendaUrl`), persistência reload/share consistente, e o seletor no header não precisa de bridge para o `calendarRef` — o FullCalendar reage a `state.view` como já reage a qualquer filtro. +**Rejeitadas:** B (o node de header é renderizado pelo shell, fora da árvore da página — provider por-página não o envolve; exigiria contexto no layout, vazamento de domínio de agenda para o shell); C (perde URL canônica/share; dois sources of truth; contraria "junto do filtro"). + +**Opções consideradas (controle do seletor):** A) Popover + botão-ghost compacto com rótulo do modo atual + ▾ | B) `NativeSelect` compacto | C) `ToggleGroup` com 4 ícones. +**Recomendação:** A — é o "Semana ▾" do esboço da intenção; adapta por superfície como o sino (`text-primary-foreground` no top bar escuro mobile vs `md:text-foreground` no header claro desktop); um único controle compacto na linha existente (não cresce o header). Opções com check do modo ativo. +**Rejeitadas:** B (usa tokens `--field-*` claros — destoa do `bg-primary` do top bar mobile; sem trigger customizável); C (4 segmentos não cabem na linha única — anti-goal explícito da intenção). + +**Opções consideradas (reconciliação com o resize):** A) gate `!state.view` no auto-switch existente (com escolha → nada muda no resize; sem escolha → comportamento atual) | B) remover o auto-switch e deixar só `initialView` por viewport | C) canonicalizar o auto-switch na URL (vira escolha). +**Recomendação:** A — mudança mínima, preserva o default responsivo atual e faz a escolha vencer; "só o default muda por viewport" da intenção permanece verdadeiro. +**Rejeitadas:** B (muda comportamento de C15 além do pedido — o resize continuaria flipando hoje); C (torna o default "escolha" — depois do primeiro resize o usuário ficaria preso no modo auto-escrito; contradiz "só o default muda por viewport"). + +**Opções consideradas (mobile):** A) renderizar o mapa `headerActions` no `CampaignMobileTopBar` (o slot C94/C95 compartilhado passa a renderizar nos dois bars; o botão do feed já se auto-esconde com `hidden md:inline-flex`) | B) registro separado mobile-only. +**Recomendação:** A — o slot foi construído como "C94/C95 shared slot"; o feed já controla a própria visibilidade por viewport; o seletor nasce visível nos dois. Uma única fonte de registro. +**Rejeitadas:** B (duplica o mecanismo de registro que C94 entregou para exatamente isso). + +**Opções consideradas (valores de URL):** A) códigos curtos `view=week|day|month|list` + mapa puro p/ FC ids | B) FC ids diretos `view=timeGridWeek`. +**Recomendação:** A — URL curta e estável (independe de internals do FullCalendar); o mapa é 3 linhas testáveis em `activityUi.ts`. +**Rejeitadas:** B (URL verbosa acoplada ao FullCalendar; renomear vista no FC vazaria para a URL). + +**Decisão registrada (label do seletor sem `view`):** o label reflete o modo efetivo — `state.view` se presente, senão o default responsivo do viewport (`window.innerWidth < 640` → "Dia", senão "Semana"), medido com `matchMedia` + settle de hidratação (`useNarrowMeasured` em `hooks/use-mobile.ts`). A janela residual (viewport 640–~680 sem sidebar e 768–896 com sidebar 16rem aberta, onde o card do calendário cruza 640 antes do viewport) é cosmética e só ocorre sem escolha explícita — aceita, sem bridge header↔página para o estado efetivo do FC. + +### Componentes / mudanças + +- **`src/utilities/activityUi.ts`** (extender): `activityAgendaViews = ['week','day','month','list']`, `ActivityAgendaView` type, `activityAgendaViewLabels` (Semana/Dia/Mês/Lista), `activityAgendaViewFcId` (week→timeGridWeek, day→timeGridDay, month→dayGridMonth, list→listMonth), `isActivityAgendaView` guard. `parseActivityAgendaParams` ganha `view` (fail-closed: valor desconhecido → undefined → redirect canônico); `buildActivityAgendaSearchParams` serializa `view` ao final (ordem canônica `municipality,deputyPresent,tag,view`); `activityAgendaParamNames` += `view`; `restrictActivityAgendaState` repassa `view` (sem restrição de acesso — é estado de UI, não recorte de dados); `parseActivityAgendaReturnHref` preserva `view` no round-trip do create. +- **`ActivityAgendaFilters.tsx`** (1 linha): "Limpar" preserva `view` (limpa filtros, não o modo de visualização — `setDraft`/`navigate` mantêm `view` atual). +- **`AgendaViewSelector.tsx`** (novo, client, `components/campaign/activity/`): Popover compacto — trigger `Button` ghost `h-9 gap-1 px-2` com label do modo efetivo + `ChevronDown`, classes adaptativas `text-primary-foreground hover:bg-primary-foreground/10 md:text-foreground md:hover:bg-muted` (padrão do sino); `aria-label="Modo de visualização"`, `aria-expanded`/`aria-haspopup`. PopoverContent com 4 opções (`role="menu"`/`menuitemradio` + `aria-checked`, check do ativo); cada opção navega `{...state, view}` via `useCampaignListFilterNavigation` (mesmo hook dos filtros → dim do pending boundary na troca). Label fallback sem `view`: `matchMedia('(max-width: 639px)')` com settle de hidratação. +- **`AgendaViewChrome.tsx`** (novo, client, espelho de `AgendaFeedChrome`): recebe `state` da página; renderiza `{selector memoizado}`. Montado na página **antes** de `` → ordem de inserção no mapa garante cluster `[Semana ▾][Link de import][Notificações][IA]` (gate da intenção). +- **`agenda/page.tsx`**: renderiza `` antes do ``. +- **`CampaignMobileTopBar.tsx`** (extender): renderiza `Object.entries(headerActions)` entre título e sino (`
`), espelhando o cluster do desktop; wizard-mode inalterado (early return). +- **`ActivityAgenda.tsx`**: `headerToolbar.end` removido (fica `{ start: 'prev,next today', center: 'title' }`); `initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}`; effect `[state.view]` → `calendarRef.current?.getApi().changeView(...)` (skip se já é a vista atual; `datesSet` dispara reload da janela como hoje); `applyResponsiveView` só flips quando `state.view` ausente (mantém `isNarrow` para o inline create C91). +- **Migration:** sem migration (nenhuma mudança de schema). +- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; sem opt-in novo. +- **UI:** Impeccable B — encaixe no header global existente, controle compacto, sem redesign. Shape → craft → critique → polish leve; tokens `data-theme='campaign'`. + +### Dados → forma (se aplicável) + +- Não apresento dados novos (vista = estado de tela/navegação, mesma classe do filtro). Forma do controle: dropdown compacto (decisão A acima), espelhando o padrão visual do cluster direito do header. + +## Fases verificáveis + +1. **Contrato URL (traço dominante)** — `activityUi.ts` (views/labels/mapa/parse/serialize/restrict/returnHref) + unit tests em `activityUi.unit.spec.ts` (parse fail-closed, serialização canônica com `view`, restrict repassa, returnHref preserva, mapa FC). `ActivityAgendaFilters` "Limpar" preserva view. Verificar: URLs canônicas existentes inalteradas para estados sem `view`. +2. **Seletor + wiring do header** — `AgendaViewSelector` (Popover compacto + navegação), `AgendaViewChrome` (registro), página (ordem), `CampaignMobileTopBar` (slot no mobile); `ActivityAgenda` (headerToolbar sem `end`, initialView, effect changeView, gate do responsive). +3. **Gates** — e2e da agenda atualizado (os asserts `getByRole('tab', /semana|mês|lista/i)` morrem com a remoção do `end` — reescrever para o seletor do header, desktop e mobile); `pnpm gate:fast` na iteração; `pnpm push` no fechamento. + +## Rabbit holes / Não escopo (engenharia) + +- Mover prev/next/"Hoje" para o header (fica no FullCalendar — corte da intenção). +- Persistência entre dispositivos/contas (só URL/tela). +- Bridge de estado efetivo do FC para o header (label fallback usa viewport; janela cosmética aceita). +- Extrair `useNarrow` compartilhado calendário/seletor (2 call sites com alvos de medição diferentes; DRY <3). +- Redesign do FullCalendar (C15) e do slot de header (C94). + +## Riscos e mitigação + +- **E2E:** os asserts de `role="tab"` do FullCalendar quebram ao remover `end` — reescrever para o seletor (label `Modo de visualização` + opções), cobrindo desktop e mobile, e adicionar: troca de vista → URL `view=...` → reload persiste → escolha vence resize. +- **`useCampaignListFilterNavigation` no header:** o node renderiza no header, dentro de `CampaignListPendingBoundary` do layout (verificado) — transição compartilhada funciona; `router.replace` com `scroll: false`. +- **`SetCampaignHeaderAction` re-registra em re-render:** node memoizado (`useMemo` em `[state]`), padrão C94. +- **Eventos ao trocar vista:** `changeView` → `datesSet` → `visibleRange` muda → reload da janela (mesmo caminho dos botões atuais do FC; sem regressão). +- **Estado com `view` na action do servidor:** `loadActivityAgendaEvents({...state})` ganha `view` — zod `activityAgendaRequestSchema` (non-strict) descarta chaves desconhecidas; sem mudança de schema (verificado). +- **Flake pré-existente (`gridcell.nth(1)`):** os testes de slot do calendário (`cria inline`, `Mais detalhes`) flakavam com "Element is not attached to the DOM" no `scrollIntoViewIfNeeded` — o grid do FullCalendar pinta vazio e **substitui os gridcells quando os events chegam**, então o scroll corria contra o re-render. Estabilizado nesta entrega: esperar `Carregando compromissos…` sumir (`toHaveCount(0)`) antes de interagir nos 2 testes de slot (mesmo padrão de waits do C94, agora antes da ação). Suíte `campaignActivity.e2e.spec.ts` completa verde (6/6) com o rebase em main. O QueryError `endAt cannot be queried` apareceu 1×/9 runs (intermitente, em run sem relação com os testes que falharam; não reproduzido após o rebase). + +## Pós-/simplify (reviewers paralelos) + +- **P1 corrigido (desync URL→calendário):** o effect de `changeView` só sincronizava quando `state.view` estava presente; navegar para `/campanha/agenda` sem query (link da sidebar, back/forward) deixava o FullCalendar numa vista órfã (ex: mês) que o seletor não reivindicava. Agora o effect normaliza também na ausência: volta ao default responsivo (narrow → `timeGridDay`, senão `timeGridWeek`) — mesma regra do label do seletor. E2E cobre o caso (goto sem `view` → "Dia" + grid de dia). +- **P1 corrigido (e2e só provava o label):** os asserts do teste C95 agora verificam o calendário de fato — o `aria-label` do botão "Hoje" ("Esta semana"/"Este mês"/"Hoje" — o FullCalendar v7 classic hasheia as classes de view) + presença/ausência de `[role="grid"]` (month tem grid, list não). Coberto após troca, após reload, após resize (escolha vence) e após navegação sem `view`. +- **P2 corrigido (teclado do menu):** `nav[role="menu"]` ganhou `onKeyDown` com ArrowUp/ArrowDown/ArrowRight/ArrowLeft/Home/End movendo o foco entre os `menuitemradio` (roving focus mínimo; Escape/foco de volta já vêm do Radix Popover). +- **P2 corrigido (3ª cópia do hook de viewport):** `use-mobile.ts` ganhou `useNarrowMeasured(breakpoint)` (mesmo settle de hidratação de `useIsMobileMeasured`); o seletor usa `useNarrowMeasured(640)` em vez de reimplementar matchMedia. `MOBILE_BREAKPOINT` voltou a ser const interna (knip). +- **P2 corrigido (labels sem assert unit):** `activityAgendaViewLabels` pinado em unit test (typo "Mêes" falharia); parse de `view=day|list` coberto. +- **P3 (banda de divergência):** corrigida a estimativa no doc — com sidebar de 16rem a janela real é 768–896px (não 640–680). +- **P3 (Limpar sem cobertura):** e2e do "abre a semana" ganhou assert — após "Limpar", a URL mantém `view=list` (filtros vão, vista fica). + +## Débitos deferidos (gatilho de revisitação) + +| Débito | Gatilho | +| ------ | ------- | +| `menuitemradio` sem typeahead; anchor híbrido mantém ctrl/middle-click abrindo href (comportamento de link, não de radio) | Próxima Issue que tocar a11y do menu do header | +| Sem caminho de UI para "voltar ao modo automático" (uma vez escolhida, `view` fica na URL; "Limpar" preserva de propósito) | Se produto pedir default responsivo restaurado por um gesto | +| `useNarrowViewport` do seletor (viewport 640) vs `applyResponsiveView` do calendário (container 640) — banda residual documentada (768–896 com sidebar) | Se o seletor e o calendário precisarem concordar sempre (mudar o probe do calendário para viewport) | + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto (seletor único no header desktop+mobile, agenda-contextual, compacto; botões FC removidos; persistência junto do filtro; escolha vence resize; sem regressão de navegação/inline/lockdown) +- [ ] Invariantes AGENTS/engineering-standards (URL canônica, staff gate, sem Consent novo, sem migration) +- [ ] Testes de domínio: unit do contrato URL (`view` parse/serialize/restrict/returnHref/mapa); e2e da agenda reescrito no seletor + persistência + escolha-vence-resize +- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index f9a0a9ed..b0fb58e2 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -10,6 +10,7 @@ import { import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda' import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters' import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome' +import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome' import { CampaignListPendingBoundary, CampaignListResults, @@ -55,6 +56,9 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { return ( + {/* C95: mounted before AgendaFeedChrome so the header cluster orders + [Semana ▾][Link de import][Notificações][IA] (gate da intenção). */} + ({ id: f.id, diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index 9b32d839..f71bbf83 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -36,7 +36,11 @@ import { activityStatusLabels, type ActivityStatus, } from '@/lib/schemas/activity' -import { activitySlotPrefill, type ActivityAgendaState } from '@/utilities/activityUi' +import { + activityAgendaViewFcId, + activitySlotPrefill, + type ActivityAgendaState, +} from '@/utilities/activityUi' import type { ActivityAgendaEvent } from '@/utilities/activityViewModels' import './ActivityAgenda.css' @@ -140,6 +144,9 @@ export const ActivityAgenda = ({ narrowRef.current = isNarrow setIsNarrow((current) => (current === isNarrow ? current : isNarrow)) if (previous !== null && isNarrow === previous) return + // C95 — an explicit view in the URL wins over the responsive week↔day + // fallback; without one the narrow default keeps working as before. + if (state.view) return const viewType = api.view.type if (isNarrow && viewType === 'timeGridWeek') { api.changeView('timeGridDay') @@ -153,7 +160,24 @@ export const ActivityAgenda = ({ const observer = new ResizeObserver(applyResponsiveView) observer.observe(container) return () => observer.disconnect() - }, []) + }, [state.view]) + + useEffect(() => { + const api = calendarRef.current?.getApi() + if (!api) return + if (state.view) { + const fcId = activityAgendaViewFcId[state.view] + if (api.view.type !== fcId) api.changeView(fcId) + return + } + // The URL lost the explicit view (sidebar link, back/forward): return to + // the responsive default so the calendar never keeps an orphan view (e.g. + // month) the header selector no longer claims. + const isNarrow = + (containerRef.current?.getBoundingClientRect().width ?? 0) < MOBILE_BREAKPOINT_PX + const fallback = isNarrow ? 'timeGridDay' : 'timeGridWeek' + if (api.view.type !== fallback) api.changeView(fallback) + }, [state.view]) useEffect(() => { if (!visibleRange) return @@ -303,11 +327,10 @@ export const ActivityAgenda = ({ locale={ptBrLocale} timeZone="America/Bahia" firstDay={1} - initialView="timeGridWeek" + initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'} headerToolbar={{ start: 'prev,next today', center: 'title', - end: 'timeGridWeek,timeGridDay,dayGridMonth,listMonth', }} height="auto" slotMinTime="07:00:00" diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx index ee99a6a7..6b6980da 100644 --- a/src/components/campaign/activity/ActivityAgendaFilters.tsx +++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx @@ -92,9 +92,12 @@ export const ActivityAgendaFilters = ({ const runAction = (action: ActivityAgendaOmniboxAction) => { if (action.kind === 'clear') { + // C95 — "Limpar" clears the recorte, not the view mode: the view is + // screen state alongside the filter, so it survives the clear. + const next: ActivityAgendaState = draft.view ? { view: draft.view } : {} setQuery('') - setDraft({}) - navigate({}) + setDraft(next) + navigate(next) return } setDraft(action.state) diff --git a/src/components/campaign/activity/AgendaViewChrome.tsx b/src/components/campaign/activity/AgendaViewChrome.tsx new file mode 100644 index 00000000..519c29da --- /dev/null +++ b/src/components/campaign/activity/AgendaViewChrome.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useMemo } from 'react' + +import { AgendaViewSelector } from '@/components/campaign/activity/AgendaViewSelector' +import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext' +import type { ActivityAgendaState } from '@/utilities/activityUi' + +/** + * C95 — agenda page → app chrome bridge for the view-mode selector: registers + * the compact dropdown into the app header (desktop cluster + mobile top bar) + * via the C94/C95 shared header-action slot. Mounted before `AgendaFeedChrome` + * so the cluster renders `[Semana ▾][Link de import][Notificações][IA]`. + */ +export const AgendaViewChrome = ({ state }: { state: ActivityAgendaState }) => { + const selector = useMemo(() => , [state]) + + return {selector} +} diff --git a/src/components/campaign/activity/AgendaViewSelector.tsx b/src/components/campaign/activity/AgendaViewSelector.tsx new file mode 100644 index 00000000..446f335b --- /dev/null +++ b/src/components/campaign/activity/AgendaViewSelector.tsx @@ -0,0 +1,120 @@ +'use client' + +import { CheckIcon, ChevronDownIcon } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' +import { useNarrowMeasured } from '@/hooks/use-mobile' +import { cn } from '@/lib/utils' +import { + activityAgendaViewLabels, + activityAgendaViews, + buildActivityAgendaHref, + type ActivityAgendaState, + type ActivityAgendaView, +} from '@/utilities/activityUi' + +/** + * Mirrors the calendar's own mobile fallback (`MOBILE_BREAKPOINT_PX` in + * `ActivityAgenda`): the selector's fallback label must agree with the view the + * calendar will pick on a narrow screen. + */ +const NARROW_VIEWPORT_PX = 640 + +/** + * C95 — the agenda-contextual view-mode selector rendered inside the app + * header (desktop cluster / mobile top bar). Registered by `AgendaViewChrome`, + * so it lives outside the page tree but navigates with the page's own URL + * contract (`buildActivityAgendaHref`) and the shared list transition. + */ +export const AgendaViewSelector = ({ state }: { state: ActivityAgendaState }) => { + const [open, setOpen] = useState(false) + const { isNarrow, measured } = useNarrowMeasured(NARROW_VIEWPORT_PX) + const effectiveView: ActivityAgendaView = state.view ?? (measured && isNarrow ? 'day' : 'week') + + const options = useMemo( + () => + activityAgendaViews.map((view) => ({ + view, + href: buildActivityAgendaHref({ ...state, view }), + })), + [state], + ) + + const moveFocus = (event: React.KeyboardEvent, fromIndex: number) => { + const direction = + event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : event.key === 'Home' ? -2 : 0 + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitemradio"]'), + ) + if (items.length === 0) return + let next: number + if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = items.length - 1 + else if (direction !== 0) next = (fromIndex + direction + items.length) % items.length + else return + items[next]?.focus() + event.preventDefault() + } + + return ( + + + + + + + + + ) +} diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx index 65e96246..8d228a7e 100644 --- a/src/components/campaign/shell/CampaignMobileTopBar.tsx +++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx @@ -1,11 +1,12 @@ 'use client' import { ArrowLeft, X } from 'lucide-react' -import type { ReactNode } from 'react' +import { Fragment, type ReactNode } from 'react' import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink' import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext' +import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay' import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext' import { Button } from '@/components/ui/button' @@ -31,6 +32,7 @@ const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary- export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => { const chrome = useCampaignWizardChrome() const homeSearchChrome = useCampaignHomeSearchChrome() + const headerActions = useCampaignHeaderActions() const { isPending } = useCampaignListTransition() if (chrome) { @@ -130,7 +132,12 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: )} - {notificationBell ?
{notificationBell}
: null} +
+ {Object.entries(headerActions).map(([id, node]) => ( + {node} + ))} + {notificationBell ?? null} +
) } diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index b40daf28..852edbe3 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -2,24 +2,24 @@ import * as React from 'react' const MOBILE_BREAKPOINT = 768 -function useMobileMeasured(): boolean | undefined { - const [isMobile, setIsMobile] = React.useState(undefined) +function useViewportNarrowMeasured(breakpoint: number): boolean | undefined { + const [isNarrow, setIsNarrow] = React.useState(undefined) React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`) const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + setIsNarrow(window.innerWidth < breakpoint) } mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + setIsNarrow(window.innerWidth < breakpoint) return () => mql.removeEventListener('change', onChange) - }, []) + }, [breakpoint]) - return isMobile + return isNarrow } export function useIsMobile() { - return !!useMobileMeasured() + return !!useViewportNarrowMeasured(MOBILE_BREAKPOINT) } /** @@ -29,6 +29,16 @@ export function useIsMobile() { * migration logic (B167) — the hydration settle must not be treated as a resize. */ export function useIsMobileMeasured(): { isMobile: boolean; measured: boolean } { - const isMobile = useMobileMeasured() + const isMobile = useViewportNarrowMeasured(MOBILE_BREAKPOINT) return { isMobile: !!isMobile, measured: isMobile !== undefined } } + +/** + * Generic viewport-narrow signal with the same hydration settle as + * `useIsMobileMeasured`, at a caller-chosen breakpoint (C95 — the agenda view + * selector mirrors the calendar's own narrow threshold). + */ +export function useNarrowMeasured(breakpoint: number): { isNarrow: boolean; measured: boolean } { + const isNarrow = useViewportNarrowMeasured(breakpoint) + return { isNarrow: !!isNarrow, measured: isNarrow !== undefined } +} diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 9c35c840..db02f253 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -42,13 +42,39 @@ export type ActivityListState = { type RawSearchParams = CampaignListRawSearchParams +/** + * C95 — the agenda view modes behind the header selector. Short URL values map + * to FullCalendar view ids via `activityAgendaViewFcId` (deliberately decoupled + * from the calendar's internals). + */ +export const activityAgendaViews = ['week', 'day', 'month', 'list'] as const +export type ActivityAgendaView = (typeof activityAgendaViews)[number] + +export const activityAgendaViewLabels: Record = { + week: 'Semana', + day: 'Dia', + month: 'Mês', + list: 'Lista', +} + +export const activityAgendaViewFcId: Record = { + week: 'timeGridWeek', + day: 'timeGridDay', + month: 'dayGridMonth', + list: 'listMonth', +} + +const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView => + activityAgendaViews.includes(value as ActivityAgendaView) + export type ActivityAgendaState = { municipality?: number deputyPresent?: true tag?: string + view?: ActivityAgendaView } -const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag'] as const +const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag', 'view'] as const const activityAgendaParamNameSet = new Set(activityAgendaParamNames) const activityTag = (value: string | undefined): string | undefined => { @@ -60,11 +86,14 @@ export const parseActivityAgendaParams = (params: RawSearchParams): ActivityAgen const municipality = strictDecimalInteger(firstValue(params.municipality)) const deputyPresent = firstValue(params.deputyPresent) === '1' ? true : undefined const tag = activityTag(firstValue(params.tag)) + const rawView = firstValue(params.view) + const view = isActivityAgendaView(rawView) ? rawView : undefined return { ...(municipality ? { municipality } : {}), ...(deputyPresent ? { deputyPresent } : {}), ...(tag ? { tag } : {}), + ...(view ? { view } : {}), } } @@ -73,6 +102,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL municipality: state.municipality === undefined ? undefined : String(state.municipality), deputyPresent: state.deputyPresent ? '1' : undefined, tag: state.tag, + view: state.view, }) const params = new URLSearchParams() @@ -81,6 +111,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL } if (canonicalState.deputyPresent) params.set('deputyPresent', '1') if (canonicalState.tag) params.set('tag', canonicalState.tag) + if (canonicalState.view) params.set('view', canonicalState.view) return params } @@ -100,6 +131,7 @@ export const restrictActivityAgendaState = ( : {}), ...(state.deputyPresent ? { deputyPresent: true } : {}), ...(state.tag && accessibleTags.has(state.tag) ? { tag: state.tag } : {}), + ...(state.view ? { view: state.view } : {}), }) export const resolveActivityAgendaUrl = ( @@ -213,6 +245,7 @@ export const parseActivityAgendaReturnHref = ( municipality: returnUrl.searchParams.get('municipality') ?? undefined, deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined, tag: returnUrl.searchParams.get('tag') ?? undefined, + view: returnUrl.searchParams.get('view') ?? undefined, }), accessibleMunicipalityIDs, accessibleTags, diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index b5b311a2..3ccb532d 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -92,9 +92,11 @@ test.describe('Agenda — calendário operacional', () => { await expect(campaignPageChrome(page, 'Agenda')).toBeVisible() await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 }) - await expect(page.getByRole('tab', { name: /semana/i })).toBeVisible() - await expect(page.getByRole('tab', { name: /mês/i })).toBeVisible() - await expect(page.getByRole('tab', { name: /lista/i })).toBeVisible() + // C95 — the four view-mode buttons moved out of the FullCalendar toolbar + // into the header selector; the calendar toolbar keeps only prev/today/next. + await expect(page.getByRole('tab', { name: /semana|mês|lista/i })).toHaveCount(0) + const viewSelector = page.getByRole('button', { name: 'Modo de visualização: Semana' }) + await expect(viewSelector).toBeVisible() const eventLink = page.getByRole('link', { name: new RegExp(title) }) const eventBox = await eventLink.boundingBox() @@ -143,8 +145,20 @@ test.describe('Agenda — calendário operacional', () => { .filter({ visible: true }) .click() await page.setViewportSize({ width: 390, height: 844 }) - await page.getByRole('tab', { name: /lista/i }).click() + // C95 — the view-mode control lives in the app top bar on mobile too; the + // narrow fallback (no `view` param) shows "Dia" after the resize. + const mobileViewSelector = page + .getByRole('button', { name: /Modo de visualização/ }) + .filter({ visible: true }) + await expect(mobileViewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia') + await mobileViewSelector.click() + await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click() + await expect(page).toHaveURL(/view=list/) await expect(page.getByText(title, { exact: true })).toBeVisible() + + // C95 — "Limpar" clears the recorte (filters), never the view mode. + await page.getByRole('button', { name: 'Limpar', exact: true }).click() + await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`) expect( await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true) @@ -167,13 +181,15 @@ test.describe('Agenda — calendário operacional', () => { await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`) const dayCell = page.getByRole('gridcell').nth(1) - await dayCell.scrollIntoViewIfNeeded() - // FullCalendar renders the time axis lazily and re-lays out on mount — - // wait for both targets to be attached+visible before measuring, or the - // bounding boxes race the grid render. + // FullCalendar renders the time axis lazily and re-lays out on mount: the + // grid first paints empty and re-renders (replacing its gridcells) when the + // events land. Wait for the load to finish before interacting, or the + // locator's element is replaced mid-action ("Element is not attached"). + await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 }) const slotLocator = page.locator('[data-time="14:00:00"]:visible').last() await expect(slotLocator).toBeVisible() await expect(dayCell).toBeVisible() + await dayCell.scrollIntoViewIfNeeded() const slotBox = await slotLocator.boundingBox() const dayBox = await dayCell.boundingBox() if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.') @@ -219,10 +235,15 @@ test.describe('Agenda — calendário operacional', () => { await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`) const dayCell = page.getByRole('gridcell').nth(1) - await dayCell.scrollIntoViewIfNeeded() + // FullCalendar renders the time axis lazily and re-lays out on mount: the + // grid first paints empty and re-renders (replacing its gridcells) when the + // events land. Wait for the load to finish before interacting, or the + // locator's element is replaced mid-action ("Element is not attached"). + await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 }) const slotLocator = page.locator('[data-time="14:00:00"]:visible').last() await expect(slotLocator).toBeVisible() await expect(dayCell).toBeVisible() + await dayCell.scrollIntoViewIfNeeded() const slotBox = await slotLocator.boundingBox() const dayBox = await dayCell.boundingBox() if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.') @@ -250,4 +271,70 @@ test.describe('Agenda — calendário operacional', () => { if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.') expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000) }) + + test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`${campaign.baseURL}/campanha/agenda`) + + const viewSelector = page + .getByRole('button', { name: /Modo de visualização/ }) + .filter({ visible: true }) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Semana') + + // Desktop: switch to month; the URL gains `view=month` and the calendar + // actually renders the month grid (not just the selector label). The + // FullCalendar v7 classic theme hashes its view classes, so the stable + // signal is the "Hoje" button's aria-label ("Este mês") plus the grid. + await viewSelector.click() + await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click() + await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês') + await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('[role="grid"]')).toBeVisible() + + // Persists across a reload (screen state lives beside the filter, on the URL). + await page.reload() + await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês') + await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) + + // The explicit choice wins over the responsive narrow fallback: shrinking + // the viewport must NOT push the calendar back to day/week. + // B167: a chat that opened by itself on the desktop panel (RRP settle) + // migrates to the full-screen mobile drawer and covers the page — close it + // first so the top bar selector stays reachable in the a11y tree. + await page + .getByRole('button', { name: 'Fechar', exact: true }) + .filter({ visible: true }) + .click() + await page.setViewportSize({ width: 390, height: 844 }) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês') + await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) + + // The mobile top bar selector still switches modes. The list view shares + // the "Este mês" today label but drops the calendar grid. + await viewSelector.click() + await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click() + await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista') + await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('[role="grid"]')).toHaveCount(0, { timeout: 15_000 }) + + // A navigation that drops the `view` param returns the calendar to the + // responsive default instead of keeping an orphan view the selector no + // longer claims (sidebar link / back-forward). Still on the 390px + // viewport, so the narrow default is day ("Hoje" today label, grid back). + await page.goto(`${campaign.baseURL}/campanha/agenda`) + await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia') + await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toBeVisible({ + timeout: 15_000, + }) + await expect(page.locator('[role="grid"]')).toBeVisible() + }) }) diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts index 2e7d1aae..3c770117 100644 --- a/tests/unit/activityUi.unit.spec.ts +++ b/tests/unit/activityUi.unit.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { + activityAgendaViewFcId, + activityAgendaViewLabels, + activityAgendaViews, activitySlotPrefill, activityTabs, buildActivityAgendaHref, @@ -205,6 +208,74 @@ describe('activity agenda URL contract', () => { }) }) +describe('activity agenda view modes (C95)', () => { + it('parses only the declared view codes fail-closed', () => { + expect(parseActivityAgendaParams({ view: 'month' }).view).toBe('month') + expect(parseActivityAgendaParams({ view: 'week' }).view).toBe('week') + expect(parseActivityAgendaParams({ view: 'day' }).view).toBe('day') + expect(parseActivityAgendaParams({ view: 'list' }).view).toBe('list') + expect(parseActivityAgendaParams({ view: 'bogus' }).view).toBeUndefined() + expect(parseActivityAgendaParams({ view: 'timeGridMonth' }).view).toBeUndefined() + }) + + it('maps every declared view code to a FullCalendar view id', () => { + expect(activityAgendaViews).toEqual(['week', 'day', 'month', 'list']) + expect(activityAgendaViewFcId).toEqual({ + week: 'timeGridWeek', + day: 'timeGridDay', + month: 'dayGridMonth', + list: 'listMonth', + }) + }) + + it('labels every declared view mode in pt-BR for the header selector', () => { + expect(activityAgendaViewLabels).toEqual({ + week: 'Semana', + day: 'Dia', + month: 'Mês', + list: 'Lista', + }) + }) + + it('serializes the view last in the canonical agenda query', () => { + expect( + buildActivityAgendaSearchParams({ + municipality: 12, + deputyPresent: true, + tag: 'Comício', + view: 'month', + }).toString(), + ).toBe('municipality=12&deputyPresent=1&tag=Com%C3%ADcio&view=month') + expect(buildActivityAgendaHref({ view: 'day' })).toBe('/campanha/agenda?view=day') + }) + + it('canonicalizes an unknown view out of the URL', () => { + expect(resolveActivityAgendaUrl({ view: 'bogus', municipality: '12' }).redirectHref).toBe( + '/campanha/agenda?municipality=12', + ) + expect(resolveActivityAgendaUrl({ view: 'month' }).redirectHref).toBeUndefined() + }) + + it('keeps the view when restricting the agenda state', () => { + expect( + restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()), + ).toEqual({ view: 'list' }) + }) + + it('round-trips the view through the create return href', () => { + expect( + parseActivityAgendaReturnHref( + '/campanha/agenda?municipality=12&view=month', + new Set([12]), + new Set(), + ), + ).toBe('/campanha/agenda?municipality=12&view=month') + expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe( + '/campanha/agenda', + ) + }) +}) + describe('buildActivityAgendaWhere', () => { it('combines visible-range overlap with municipality, deputy and tag filters', () => { expect( From d05e299f262e4b216d132cc7b080c4717b67c85f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 12:35:29 -0300 Subject: [PATCH 2/3] style(C95): formata impl plan (prettier) --- docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md b/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md index c7081c58..4288e120 100644 --- a/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md +++ b/docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md @@ -106,10 +106,10 @@ flowchart LR ## Débitos deferidos (gatilho de revisitação) -| Débito | Gatilho | -| ------ | ------- | -| `menuitemradio` sem typeahead; anchor híbrido mantém ctrl/middle-click abrindo href (comportamento de link, não de radio) | Próxima Issue que tocar a11y do menu do header | -| Sem caminho de UI para "voltar ao modo automático" (uma vez escolhida, `view` fica na URL; "Limpar" preserva de propósito) | Se produto pedir default responsivo restaurado por um gesto | +| Débito | Gatilho | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `menuitemradio` sem typeahead; anchor híbrido mantém ctrl/middle-click abrindo href (comportamento de link, não de radio) | Próxima Issue que tocar a11y do menu do header | +| Sem caminho de UI para "voltar ao modo automático" (uma vez escolhida, `view` fica na URL; "Limpar" preserva de propósito) | Se produto pedir default responsivo restaurado por um gesto | | `useNarrowViewport` do seletor (viewport 640) vs `applyResponsiveView` do calendário (container 640) — banda residual documentada (768–896 com sidebar) | Se o seletor e o calendário precisarem concordar sempre (mudar o probe do calendário para viewport) | ## Aceite de engenharia From 4fbb92b886a6b1c94d7f686dde5a37270e617572 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 12:42:21 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(e2e):=20C95=20=E2=80=94=20grid=20assert?= =?UTF-8?q?s=20com=20first()=20(FC=20v7=20mant=C3=A9m=20grids=20antigos=20?= =?UTF-8?q?no=20DOM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/campaignActivity.e2e.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 3ccb532d..9fb06499 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -290,13 +290,14 @@ test.describe('Agenda — calendário operacional', () => { // Desktop: switch to month; the URL gains `view=month` and the calendar // actually renders the month grid (not just the selector label). The // FullCalendar v7 classic theme hashes its view classes, so the stable - // signal is the "Hoje" button's aria-label ("Este mês") plus the grid. + // signal is the "Hoje" button's aria-label ("Este mês") plus a grid. + // (`.first()`: FC keeps stale view grids in the DOM while transitioning.) await viewSelector.click() await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click() await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`) await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês') await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('[role="grid"]')).toBeVisible() + await expect(page.getByRole('grid').first()).toBeVisible() // Persists across a reload (screen state lives beside the filter, on the URL). await page.reload() @@ -324,7 +325,7 @@ test.describe('Agenda — calendário operacional', () => { await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`) await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista') await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('[role="grid"]')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.getByRole('grid')).toHaveCount(0, { timeout: 15_000 }) // A navigation that drops the `view` param returns the calendar to the // responsive default instead of keeping an orphan view the selector no @@ -335,6 +336,6 @@ test.describe('Agenda — calendário operacional', () => { await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toBeVisible({ timeout: 15_000, }) - await expect(page.locator('[role="grid"]')).toBeVisible() + await expect(page.getByRole('grid').first()).toBeVisible() }) })