diff --git a/docs/plans/c101-agenda-mobile-calendario-nativo-impl.md b/docs/plans/c101-agenda-mobile-calendario-nativo-impl.md new file mode 100644 index 00000000..20a7c35a --- /dev/null +++ b/docs/plans/c101-agenda-mobile-calendario-nativo-impl.md @@ -0,0 +1,84 @@ +# Impl: C101 — Agenda mobile: calendário com cara de app nativo + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #497 +Intenção: docs/plans/c101-agenda-mobile-calendario-nativo.md +Appetite restante: ~1,5–2 dias eng (herdado) + +## Leitura da intenção + +- **Outcome:** no celular, `/campanha/agenda` se comporta como calendário nativo: contexto do período no header do app (no lugar de "Agenda"), navegação por arrasto horizontal, filtro edge-to-edge sem rótulo e sempre na tela, visão de dia abrindo na hora atual (fixa em 08:00) com o cabeçalho "domingo 9" fixo ao rolar. +- **O que NÃO negociar:** desktop intacto (título + "< > Hoje" do FullCalendar permanecem); criação inline por tap (C91) e remanejo por arrasto (C15) intactos; seletor de vista C95 no header; líder lockdown; sem segunda barra de controles; sem mudar o filtro das outras listas. +- **O que reavaliar:** a hipótese da intenção de que o cabeçalho do dia e o scroll da linha do tempo vêm do `scrollTime` existente — **verificado no browser: hoje não há scroll interno** (height auto → a página rola, o col-header "domingo 9" rola junto, `scrollTime` é inerte). O aceite exige scroll interno + cabeçalho fixo → precisa de altura fixa no mobile. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[ActivityAgenda (mobile)] --> B[datesSet → visibleRange+view] + B --> C[activityAgendaPeriodLabel pure] + C --> D[AgendaPeriodChrome → SetCampaignPageChrome] + D --> H[TopBar: título período + tap→today] + A --> G[useAgendaSwipeNavigation] + G --> F[api.next/prev + touchcancel sintético] + A --> I[headerToolbar=false + height liquid + scrollTimeReset=false] + I --> J[FC: scroll interno, col-header fixo, 08:00] + K[strip: .activity-agenda-filter-strip] --> L[edge-to-edge, sem label, sticky] +``` + +**Opções consideradas:** A | B | C | D +**Recomendação:** A — combina os mecanismos que o código já possui (chrome override por página, ResizeObserver/breakpoints, FC height/scrollTime) sem collection, sem lib nova, sem tocar nas outras listas. +**Rejeitadas:** B — capar o corpo do timegrid por CSS (max-height + overflow) dessincroniza o eixo de horas do corpo (FC só sincroniza scrollers quando `verticalScrolling`); C — `contentHeight` fixo em px ignora o tamanho real do viewport (quebra em telas menores); D — deixar como está (rolagem de página) falha o aceite "cabeçalho do dia fixo". + +### Componentes / mudanças + +- **`activityAgendaPeriodLabel`** (`src/utilities/activityUi.ts`): pura — `(view, startStr, endStr) => string | null`; usa `formatBahiaCivilDate` + nomes de mês pt-BR em array estático (sem Intl). `day → "9 Agosto"`, `week → "3–9 Agosto" | "28 Julho – 3 Agosto"` (end−1), `month → "Agosto"`, `list → "Agenda"`, inválido → `null`. Inversa `activityAgendaViewFromFcId` (espelha `activityAgendaViewFcId`). +- **`ActivityAgenda.tsx`** (`src/components/campaign/activity/ActivityAgenda.tsx`): estado `visibleRange` ganha `view` (de `DatesSetInfo.view.type`); condicionais por `useIsMobileMeasured()` (janela <768 — o mesmo breakpoint da top bar `md:hidden`; **não** o container 640, para não vazar mobile-ismo no desktop 768–800 com sidebar): + - `headerToolbar={isMobile ? false : { start: 'prev,next today', center: 'title' }}` + - `height={isMobile ? '100%' : 'auto'}` + CSS `.activity-agenda { height: calc(100svh - …) }` no media ≤767px (tune no browser) → FC liga `verticalScrolling` → scroll interno + col-header fora do scroller (fixo por construção) + `scrollTime` deixa de ser inerte. + - `scrollTimeReset={!isMobile}` → swipe dia→dia **preserva** o scroll do usuário; desktop mantém o reset atual. + - `eventDragStart/eventDragStop` → ref `isDraggingEvent` (guarda do gesto). + - label memo + `AgendaPeriodChrome` + `onToday` (useCallback: `api.today()` + `api.scrollToTime('08:00:00')`). +- **`useAgendaSwipeNavigation`** (`src/components/campaign/activity/useAgendaSwipeNavigation.ts`): hook novo, agenda-only. Pointer events no container, `pointerType touch|pen`, limiar 48 px com dominância horizontal |dx| > 1.4·|dy|, um disparo por gesto. No consumo: `onSwipe('next'|'prev')` + `TouchEvent('touchcancel')` sintético no alvo do touchstart **antes** do `touchend` real — mata o timer de long-press (650 ms) do FC (senão o drag de evento começaria no meio do swipe) e o tap pendente. Enquanto `isDraggingEvent` → ignora (arrasto de remanejo é do FC). +- **`AgendaPeriodChrome`** (`src/components/campaign/activity/AgendaPeriodChrome.tsx`): ponte agenda→header (padrão do `AgendaViewChrome`); `chrome = useMemo({ title: label, onTitleClick }, [label, onTitleClick])` + `useLayoutEffect` set/cleanup (o mesmo padrão do `SetCampaignPageChrome` — o flash "Agenda" entre commits não pinta, layout effects do mesmo commit são batched). Nada renderizado em desktop (só mobile seta). +- **`CampaignPageChrome`** (`src/lib/campaignPageChrome.ts`): campo opcional `onTitleClick?: () => void`. +- **`CampaignPageChromeText.tsx`** (branch mobile): quando `onTitleClick`, o título vira ` - - - } - /> +
+ { + runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId })) + }} + onClearAll={() => { + runAction(clearActivityAgendaOmnibox()) + }} + trailing={ +
+ + +
+ } + /> +
) } diff --git a/src/components/campaign/activity/AgendaPeriodChrome.tsx b/src/components/campaign/activity/AgendaPeriodChrome.tsx new file mode 100644 index 00000000..fee378cd --- /dev/null +++ b/src/components/campaign/activity/AgendaPeriodChrome.tsx @@ -0,0 +1,31 @@ +'use client' + +import { useMemo } from 'react' + +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' + +/** + * C101 — agenda page → app header bridge for the period context: while the + * mobile calendar navigates, the top bar title shows the visible period + * ("9 Agosto", "3–9 Agosto", "Agosto") instead of the catalog "Agenda", and + * tapping it returns to today (the "Hoje" toolbar button is hidden on + * phones). Desktop never mounts this — the agenda only renders it when the + * mobile top bar is the visible chrome. + */ +export const AgendaPeriodChrome = ({ + label, + onToday, +}: { + label: string | null + onToday: () => void +}) => { + const chrome = useMemo( + () => + label ? { title: label, onTitleClick: { action: onToday, hint: 'Voltar para hoje' } } : null, + [label, onToday], + ) + + if (!chrome) return null + + return +} diff --git a/src/components/campaign/activity/useAgendaSwipeNavigation.ts b/src/components/campaign/activity/useAgendaSwipeNavigation.ts new file mode 100644 index 00000000..d5b51c83 --- /dev/null +++ b/src/components/campaign/activity/useAgendaSwipeNavigation.ts @@ -0,0 +1,221 @@ +'use client' + +import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject } from 'react' + +/** + * C101 — horizontal swipe navigation for the mobile agenda. FullCalendar has + * no swipe gesture of its own, so this hook listens for a clear horizontal + * drag on the calendar container and maps it to prev/next navigation. + * + * Chromium's pan controller claims any touch drag for scrolling the moment + * the first touchmove passes its slop — firing pointercancel and ending the + * pointer stream (on a real swipe the first move is small, so the 48px + * threshold would never be reached). The fix is the classic swipe-vs-scroll + * pattern: a NON-PASSIVE touchmove listener on the container calls + * `preventDefault` as soon as the gesture is clearly horizontal (dead zone + + * dominance), which stops the browser from claiming it. Vertical moves get no + * preventDefault, so the timegrid keeps scrolling natively (and the browser + * cancels the pointer stream — the gesture then resets, which is correct). + * + * Contract with FullCalendar's touch interactions (FC runs on touch events, + * this hook on pointer events, both on the same gesture): + * + * - Tap (inline create, C91) needs no movement: a swipe crosses the + * threshold and FC's own hit-equality (initialHit !== finalHit) already + * suppresses the dateClick. + * - The day view has a single column, so ANY horizontal drag ends with + * initialHit === finalHit and FC fires a dateClick: the caller ignores + * clicks while `suppressDateClickRef` is set. The flag arms at the 12px + * claim (a sub-threshold flick must not open the inline create either) + * and clears on the next pointerdown — a genuine tap after a failed swipe + * keeps working. + * - The event long-press (650ms, C15 reschedule) must not fire mid-swipe: + * on consume we dispatch a synthetic `touchcancel` at the touchstart + * target — browsers emit real touchcancel exactly when a gesture is taken + * away, and FC treats it as a touch end, clearing its pending timers. + * - A drag FC already owns (eventDragStart/eventResizeStart fired) is + * blocked via `blockRef`: slow horizontal drags on events stay + * reschedules. + * + * Only touch/pen pointers navigate — mouse keeps the desktop toolbar. + */ + +const AGENDA_SWIPE_THRESHOLD_PX = 48 +const AGENDA_SWIPE_DOMINANCE = 1.4 +/** Horizontal movement that already proves intent (pan claim dead zone). */ +const AGENDA_SWIPE_CLAIM_PX = 12 +/** Backstop for the dateClick suppression when no next gesture ever comes. */ +const AGENDA_CLICK_SUPPRESSION_MS = 500 + +type AgendaSwipeDirection = 'next' | 'prev' + +type AgendaSwipeGesture = { + pointerId: number + startX: number + startY: number + startTarget: EventTarget | null + consumed: boolean +} + +export const useAgendaSwipeNavigation = ({ + containerRef, + enabled, + blockRef, + onSwipe, +}: { + containerRef: RefObject + /** Render-time gate (viewport/state-driven): when false, no gesture starts. */ + enabled: boolean + /** Event-time gate: when true, an in-flight gesture is abandoned (FC drag owns the pointer). */ + blockRef: MutableRefObject + onSwipe: (direction: AgendaSwipeDirection) => void +}): { suppressDateClickRef: MutableRefObject } => { + const gestureRef = useRef(null) + const enabledRef = useRef(enabled) + const blockRefRef = useRef(blockRef) + const onSwipeRef = useRef(onSwipe) + const suppressTimerRef = useRef | null>(null) + /** + * The day view has a single column, so a horizontal drag never leaves the + * slot it started on: FullCalendar's dateClick fires (initialHit === + * finalHit) at the end of the gesture. The caller ignores that click — + * the drag either navigated (consume) or was a sub-threshold flick, and + * neither may open the inline create. + */ + const suppressDateClickRef = useRef(false) + + enabledRef.current = enabled + blockRefRef.current = blockRef + onSwipeRef.current = onSwipe + + const armClickSuppression = useCallback(() => { + suppressDateClickRef.current = true + if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current) + suppressTimerRef.current = setTimeout(() => { + suppressTimerRef.current = null + suppressDateClickRef.current = false + }, AGENDA_CLICK_SUPPRESSION_MS) + }, []) + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (!enabledRef.current || blockRefRef.current.current) return + if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return + if (event.isPrimary === false) return + + // A new gesture means the previous one ended: its dateClick (if any) has + // already fired, so a genuine tap right after a failed swipe is safe. + suppressDateClickRef.current = false + if (suppressTimerRef.current) { + clearTimeout(suppressTimerRef.current) + suppressTimerRef.current = null + } + + gestureRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startTarget: event.target, + consumed: false, + } + }, []) + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + const gesture = gestureRef.current + if (!gesture || gesture.consumed || blockRefRef.current.current) return + const touch = event.touches[0] + if (!touch) return + + const deltaX = touch.clientX - gesture.startX + const deltaY = touch.clientY - gesture.startY + if ( + Math.abs(deltaX) > AGENDA_SWIPE_CLAIM_PX && + Math.abs(deltaX) > Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE + ) { + // Clearly horizontal: keep the browser from claiming the gesture for + // panning (which would pointercancel the stream before the + // threshold) AND suppress the dateClick a sub-threshold flick would + // otherwise trigger in the day view. + event.preventDefault() + armClickSuppression() + } + }, + [armClickSuppression], + ) + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const gesture = gestureRef.current + if (!gesture || gesture.consumed || event.pointerId !== gesture.pointerId) return + if (blockRefRef.current.current) { + gestureRef.current = null + return + } + + const deltaX = event.clientX - gesture.startX + const deltaY = event.clientY - gesture.startY + if (Math.abs(deltaX) < AGENDA_SWIPE_THRESHOLD_PX) return + if (Math.abs(deltaX) < Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE) { + // Vertical drag (scrolling the timeline, rescheduling): never navigate. + gesture.consumed = true + return + } + + gesture.consumed = true + + // Kill FullCalendar's pending long-press/tap timers FIRST: its touch + // listeners are attached to the touchstart target, so the cancel must be + // dispatched there while that node is still connected (synthetic + // touchcancel = the browser taking the gesture away). + const startTarget = gesture.startTarget + if (typeof TouchEvent !== 'undefined' && startTarget instanceof Element) { + startTarget.dispatchEvent( + new TouchEvent('touchcancel', { bubbles: true, cancelable: true }), + ) + } + + armClickSuppression() + onSwipeRef.current(deltaX < 0 ? 'next' : 'prev') + }, + [armClickSuppression], + ) + + const handlePointerEnd = useCallback((event: PointerEvent) => { + const gesture = gestureRef.current + if (!gesture || event.pointerId !== gesture.pointerId) return + gestureRef.current = null + }, []) + + useEffect(() => { + const container = containerRef.current + if (!container) return + container.addEventListener('pointerdown', handlePointerDown) + // Non-passive: the horizontal-claim preventDefault must reach the browser + // before the pan controller decides (see module doc). + container.addEventListener('touchmove', handleTouchMove, { passive: false }) + return () => { + container.removeEventListener('pointerdown', handlePointerDown) + container.removeEventListener('touchmove', handleTouchMove) + } + }, [containerRef, handlePointerDown, handleTouchMove]) + + useEffect(() => { + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerEnd) + window.addEventListener('pointercancel', handlePointerEnd) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerEnd) + window.removeEventListener('pointercancel', handlePointerEnd) + } + }, [handlePointerMove, handlePointerEnd]) + + useEffect( + () => () => { + if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current) + }, + [], + ) + + return { suppressDateClickRef } +} diff --git a/src/components/campaign/shell/CampaignPageChromeText.tsx b/src/components/campaign/shell/CampaignPageChromeText.tsx index 6f0c85ea..c6e94c60 100644 --- a/src/components/campaign/shell/CampaignPageChromeText.tsx +++ b/src/components/campaign/shell/CampaignPageChromeText.tsx @@ -26,9 +26,24 @@ export const CampaignPageChromeText = ({ return (
- - {chrome.title} - + {chrome.onTitleClick ? ( + + ) : ( + + {chrome.title} + + )} {chrome.subtitle ? ( {chrome.subtitle} ) : null} diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index 37074b05..1d40d29c 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -7,6 +7,15 @@ import type { CampaignUser } from '@/payload-types' export type CampaignPageChrome = { title: string subtitle?: string + /** + * C101 — page-owned action on the app-header title (mobile layout only). + * The agenda uses it to make the period label the "return to today" + * control, since the FullCalendar "Hoje" toolbar button is hidden on + * phones. Only client-rendered overrides set it — the catalog never does. + * The `hint` is the page's own copy (rendered as the button tooltip), so + * the shared chrome does not hardcode agenda wording. + */ + onTitleClick?: { action: () => void; hint: string } } type CampaignRole = CampaignUser['role'] diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index db02f253..404a5785 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -1,7 +1,11 @@ import type { Where } from 'payload' import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths' -import { parseBahiaDateTimeInput } from '@/lib/campaignTime' +import { + formatBahiaCivilDate, + parseBahiaDateTimeInput, + subtractBahiaCivilDays, +} from '@/lib/campaignTime' import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' import { activityStatuses, @@ -64,6 +68,100 @@ export const activityAgendaViewFcId: Record = { list: 'listMonth', } +/** + * Inverse of `activityAgendaViewFcId`: the FullCalendar view id reported by + * `datesSet` back to the agenda's own view vocabulary. + */ +export const activityAgendaViewFromFcId = (fcId: string): ActivityAgendaView | null => { + const match = activityAgendaViews.find((view) => activityAgendaViewFcId[view] === fcId) + return match ?? null +} + +const ptBrMonthNames = [ + 'Janeiro', + 'Fevereiro', + 'Março', + 'Abril', + 'Maio', + 'Junho', + 'Julho', + 'Agosto', + 'Setembro', + 'Outubro', + 'Novembro', + 'Dezembro', +] as const + +const civilDateParts = (civilDate: string): { day: number; month: number } | null => { + const [year, month, day] = civilDate.split('-').map(Number) + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null + } + return { day, month } +} + +const civilDateLabel = (civilDate: string): string | null => { + const parts = civilDateParts(civilDate) + if (!parts) return null + return `${parts.day} ${ptBrMonthNames[parts.month - 1]}` +} + +/** + * C101 — the period context the mobile app header shows while the calendar + * navigates: day → "9 Agosto"; week → "3–9 Agosto" (visible Mon–Sun, months + * spelled out when the range crosses one: "28 Julho – 3 Agosto"); month → + * "Agosto"; list → "Agenda" (the dates live in the list body). The month + * label uses the view's current date, NOT the grid range start: the month + * grid's visible range begins on the leading Sunday of the previous month. + * Returns null when the range cannot be read, so the header falls back to + * the catalog title instead of inventing a label. + */ +export const activityAgendaPeriodLabel = ( + view: ActivityAgendaView, + startStr: string, + endStr: string, + currentDateStr: string, +): string | null => { + if (view === 'list') return 'Agenda' + + const civilDateOf = (value: string): string | null => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : formatBahiaCivilDate(date) + } + + const start = civilDateOf(startStr) + const end = civilDateOf(endStr) + const startParts = start ? civilDateParts(start) : null + const endParts = end ? civilDateParts(end) : null + if (!start || !end || !startParts || !endParts) return null + + if (view === 'day') return civilDateLabel(start) + + // The anchor is ALREADY a Bahia civil date (`formatBahiaCivilDate` output, + // no timezone) — parsing it as an instant would re-derive it from UTC + // midnight and shift it back a day (2026-09-01 → 31 Agosto in Bahia). + const currentParts = civilDateParts(currentDateStr) + if (view === 'month') { + if (!currentParts) return null + return ptBrMonthNames[currentParts.month - 1] + } + + const weekEnd = civilDateParts(subtractBahiaCivilDays(end, 1)) + if (!weekEnd) return null + if (weekEnd.month === startParts.month) { + return `${startParts.day}–${weekEnd.day} ${ptBrMonthNames[startParts.month - 1]}` + } + return `${startParts.day} ${ptBrMonthNames[startParts.month - 1]} – ${weekEnd.day} ${ptBrMonthNames[weekEnd.month - 1]}` +} + const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView => activityAgendaViews.includes(value as ActivityAgendaView) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 9bbb2ff6..01c0977b 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -1,6 +1,7 @@ import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' +import { dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js' test.describe('Atividades — registro-fundação', () => { test.setTimeout(90_000) @@ -344,26 +345,38 @@ test.describe('Agenda — calendário operacional', () => { .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 }) + // C101 — the mobile calendar drops the FullCalendar toolbar: the period + // context lives in the app header instead (the current month's name). + await expect(page.getByRole('button', { name: 'Este mês' })).toHaveCount(0, { + timeout: 15_000, + }) + const todayCivil = formatBahiaCivilDate(new Date()) + const monthLabel = ptBrMonthNames[Number(todayCivil.slice(5, 7)) - 1] ?? '' + await expect(campaignPageChrome(page, monthLabel)).toBeVisible() - // The mobile top bar selector still switches modes. The list view shares - // the "Este mês" today label but drops the calendar grid. + // The mobile top bar selector still switches modes. The list view drops + // the calendar grid and the header goes back to the plain "Agenda" (the + // dates live in the list body). 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.getByRole('button', { name: 'Este mês' })).toHaveCount(0, { + timeout: 15_000, + }) + await expect(campaignPageChrome(page, 'Agenda')).toBeVisible() 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 // longer claims (sidebar link / back-forward). Still on the 390px - // viewport, so the narrow default is day ("Hoje" today label, grid back). + // viewport, so the narrow default is day (grid back, no toolbar: the + // header shows today's period instead of a "Hoje" button). 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, - }) + const todayLabel = dayLabelFor(formatBahiaCivilDate(new Date())) + await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0) await expect(page.getByRole('grid').first()).toBeVisible() }) }) diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts new file mode 100644 index 00000000..cc4a02b0 --- /dev/null +++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts @@ -0,0 +1,271 @@ +import type { CDPSession, Locator, Page } from '@playwright/test' + +import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' +import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' +import { + campaignPageChrome, + expect, + test, + type CampaignE2EFixture, +} from './fixtures/campaignE2EFixtures.js' +import { + civilDatePlusDays, + dayLabelFor, + ptBrMonthNames, + weekdayOf, +} from './helpers/agendaPeriodLabels.js' + +const mobileHeaderTitle = (page: Page): Locator => + page.locator('[data-slot="campaign-mobile-top-bar"] [data-slot="campaign-page-chrome-title"]') + +/** + * Horizontal swipe via the CDP touch pipeline (the same input path a real + * device takes — the only way to exercise touch gestures in Playwright). + * The swipe stays at the vertical middle of the calendar, clear of fixture + * events (which sit at 10:00), and completes far below FullCalendar's 650ms + * long-press, so it is a navigation, not a reschedule. + */ +const touchSwipe = async ( + cdp: CDPSession, + box: { x: number; y: number; width: number; height: number }, + fromRatio: number, + toRatio: number, +) => { + const y = Math.round(box.y + box.height * 0.5) + const x0 = Math.round(box.x + box.width * fromRatio) + const x1 = Math.round(box.x + box.width * toRatio) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y }] }) + for (let i = 1; i <= 5; i += 1) { + const x = Math.round(x0 + ((x1 - x0) * i) / 5) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] }) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + +const tapAt = async (cdp: CDPSession, x: number, y: number) => { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] }) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + +const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + const civilDate = formatBahiaCivilDate(new Date()) + const startAt = parseBahiaDateTimeInput(`${civilDate}T10:00`) + if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.') + + await fixtures.payload.create({ + collection: 'activity', + data: hookFilledCreateData<'activity'>({ + title: fixtures.value('Compromisso mobile nativo'), + tags: ['Comício'], + status: 'confirmado', + startAt, + municipality: municipality.id, + }), + depth: 0, + }) + await campaign.login(page, coordinator.email!, coordinator.password) + return civilDate +} + +test.describe('C101 — agenda mobile com cara de app nativo', () => { + test.setTimeout(90_000) + + test('toolbar some, o título mostra o período e tap volta ao hoje', async ({ + campaign, + page, + }) => { + const civilDate = await seedTodayActivity(campaign, page) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${campaign.baseURL}/campanha/agenda`) + const cdp = await page.context().newCDPSession(page) + + // C101 — o título do FullCalendar e os botões "< > Hoje" somem no mobile: + // o header do app mostra o contexto do período no lugar. + const todayLabel = dayLabelFor(civilDate) + await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Dia Anterior' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Próximo Dia' })).toHaveCount(0) + + // Navegação por arrasto: esquerda → dia seguinte; direita → volta. O + // swipe direito começa longe da borda esquerda (x=0.4): um gesto que + // nasça na borda é do Chrome (histórico), não da agenda. + // `.first()`: FC mantém grids de vista antigos no DOM durante transições. + const agendaBox = await page.locator('.activity-agenda').first().boundingBox() + if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.') + const nextCivil = civilDatePlusDays(civilDate, 1) + await touchSwipe(cdp, agendaBox, 0.8, 0.2) + await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({ timeout: 15_000 }) + await touchSwipe(cdp, agendaBox, 0.4, 0.85) + await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 }) + + // O swipe não abre o inline create (dia tem uma coluna só: o hit do FC + // nunca sai do slot inicial e o dateClick precisa ser engolido). + await expect(page.getByRole('dialog')).toHaveCount(0) + + // Tap no título do header volta ao hoje (o "Hoje" da toolbar não existe + // mais no mobile). Sai primeiro do hoje para o tap ter efeito. + await touchSwipe(cdp, agendaBox, 0.8, 0.2) + await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({ + timeout: 15_000, + }) + const titleBox = await mobileHeaderTitle(page).boundingBox() + if (!titleBox) throw new Error('Título do header não expôs área de toque.') + await tapAt( + cdp, + Math.round(titleBox.x + titleBox.width / 2), + Math.round(titleBox.y + titleBox.height / 2), + ) + await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 }) + + // Contexto por vista: mês → nome do mês; swipe no mês → mês seguinte; + // lista → "Agenda". + const viewSelector = page + .getByRole('button', { name: /Modo de visualização/ }) + .filter({ visible: true }) + await viewSelector.click() + await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click() + const monthIndex = Number(civilDate.slice(5, 7)) - 1 + await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({ + timeout: 15_000, + }) + await touchSwipe(cdp, agendaBox, 0.8, 0.2) + await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible( + { timeout: 15_000 }, + ) + await viewSelector.click() + await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click() + await expect(campaignPageChrome(page, 'Agenda')).toBeVisible({ timeout: 15_000 }) + + // O strip edge-to-edge não pode criar overflow horizontal. + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true) + }) + + test('visão de dia abre na hora atual e o cabeçalho do dia fica fixo ao rolar', async ({ + campaign, + page, + }) => { + const civilDate = await seedTodayActivity(campaign, page) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${campaign.baseURL}/campanha/agenda`) + + const dayLabel = dayLabelFor(civilDate) + await expect(campaignPageChrome(page, dayLabel)).toBeVisible({ timeout: 15_000 }) + + // C101 — o grid de dia vira um scroller próprio (height fixa no mobile): + // abre ancorado em 08:00 ("hoje é fixa em 08:00")... + // `.first()`: FC mantém grids de vista antigos no DOM durante transições. + await expect + .poll( + () => + page.evaluate(() => { + const scroller = [ + ...document.querySelector('.activity-agenda')!.querySelectorAll('*'), + ].find((el) => { + const style = getComputedStyle(el) + return ( + (style.overflowY === 'auto' || style.overflowY === 'scroll') && + el.scrollHeight > el.clientHeight && + el.querySelector('[role="rowheader"]') + ) + }) + return scroller ? scroller.scrollTop : null + }), + { timeout: 15_000 }, + ) + .toBeGreaterThan(0) + + // ...e o cabeçalho "domingo 9" permanece no topo enquanto os horários + // rolam. + const colHeaderText = `${weekdayOf(civilDate)} ${Number(civilDate.slice(8, 10))}` + const headerTop = () => + page.evaluate((text) => { + const cell = [ + ...document.querySelector('.activity-agenda')!.querySelectorAll('[role="columnheader"]'), + ].find((el) => el.textContent?.includes(text)) + return cell ? Math.round(cell.getBoundingClientRect().top) : null + }, colHeaderText) + + const headerBefore = await headerTop() + expect(headerBefore).not.toBeNull() + + const scrolledTo = await page.evaluate(() => { + const scroller = [...document.querySelector('.activity-agenda')!.querySelectorAll('*')].find( + (el) => { + const style = getComputedStyle(el) + return ( + (style.overflowY === 'auto' || style.overflowY === 'scroll') && + el.scrollHeight > el.clientHeight && + el.querySelector('[role="rowheader"]') + ) + }, + ) + if (!scroller) return false + scroller.scrollTop = 300 + return scroller.scrollTop + }) + expect(scrolledTo).toBeGreaterThan(0) + + expect(await headerTop()).toBe(headerBefore) + }) + + test('o filtro vira strip edge-to-edge sem rótulo, colado na barra e no calendário', async ({ + campaign, + page, + }) => { + const civilDate = await seedTodayActivity(campaign, page) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${campaign.baseURL}/campanha/agenda`) + + // Espera o layout mobile assentar (B167): o painel de chat renderiza + // "desktop com chat aberto" no primeiro frame e rouba largura do + // conteúdo até a medição do viewport — o título do período no header só + // existe no layout mobile de verdade. + await expect(campaignPageChrome(page, dayLabelFor(civilDate))).toBeVisible({ + timeout: 15_000, + }) + + // O rótulo some visualmente (sr-only: 0 de altura renderizada) mas + // continua sendo o nome acessível do campo. + const labelHeight = await page.evaluate(() => { + const label = document.querySelector('.activity-agenda-filter-strip label') + return label ? Math.round(label.getBoundingClientRect().height) : null + }) + expect(labelHeight).not.toBeNull() + expect(labelHeight!).toBeLessThanOrEqual(1) + await expect(page.getByRole('combobox', { name: 'Filtrar agenda' })).toBeVisible() + + // Edge-to-edge, encostada na barra do app e no limite superior do + // calendário, sticky enquanto navega. Tolerância de 1px para o + // arredondamento de subpixel (precedente actionGridGeometry). + const geometry = await page.evaluate(() => { + const strip = document.querySelector('.activity-agenda-filter-strip') + const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]') + const calendar = document.querySelector('.activity-agenda-shell') + if (!strip || !topBar || !calendar) return null + const stripRect = strip.getBoundingClientRect() + return { + stripTop: Math.round(stripRect.top), + topBarBottom: Math.round(topBar.getBoundingClientRect().bottom), + stripBottom: Math.round(stripRect.bottom), + calendarTop: Math.round(calendar.getBoundingClientRect().top), + stripWidth: Math.round(stripRect.width), + windowWidth: window.innerWidth, + sticky: getComputedStyle(strip).position, + } + }) + expect(geometry).not.toBeNull() + expect(Math.abs(geometry!.stripTop - geometry!.topBarBottom)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry!.stripBottom - geometry!.calendarTop)).toBeLessThanOrEqual(1) + expect(geometry!.stripWidth).toBe(geometry!.windowWidth) + expect(geometry!.sticky).toBe('sticky') + }) +}) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index c6710766..1810b127 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -516,7 +516,7 @@ export const expectCampaignBiometricsReady = async (page: Page): Promise = }).toPass({ timeout: 15_000 }) } -type CampaignE2EFixture = { +export type CampaignE2EFixture = { baseURL: string fixtures: CampaignE2EOwnership login: (page: Page, identifier: string, password: string) => Promise diff --git a/tests/e2e/helpers/agendaPeriodLabels.ts b/tests/e2e/helpers/agendaPeriodLabels.ts new file mode 100644 index 00000000..d8c613ab --- /dev/null +++ b/tests/e2e/helpers/agendaPeriodLabels.ts @@ -0,0 +1,48 @@ +/** + * Agenda mobile (C101) — the header period label is computed from the + * calendar's current date, so the e2e specs derive the expected label from + * "today" instead of hardcoding it. Shared by the agenda mobile spec and the + * C95 view-switching spec. Production vocabulary lives in + * `src/utilities/activityUi.ts`; these helpers only mirror the label FORMAT + * for assertions (the unit spec pins the literal strings). + */ + +export const ptBrMonthNames = [ + 'Janeiro', + 'Fevereiro', + 'Março', + 'Abril', + 'Maio', + 'Junho', + 'Julho', + 'Agosto', + 'Setembro', + 'Outubro', + 'Novembro', + 'Dezembro', +] + +const ptBrWeekdays = [ + 'domingo', + 'segunda-feira', + 'terça-feira', + 'quarta-feira', + 'quinta-feira', + 'sexta-feira', + 'sábado', +] + +/** "2026-08-09" → "9 Agosto" (the mobile header period label). */ +export const dayLabelFor = (civilDate: string): string => + `${Number(civilDate.slice(8, 10))} ${ptBrMonthNames[Number(civilDate.slice(5, 7)) - 1]}` + +export const civilDatePlusDays = (civilDate: string, days: number): string => { + const [year, month, day] = civilDate.split('-').map(Number) + const date = new Date(Date.UTC(year, month - 1, day + days)) + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}` +} + +export const weekdayOf = (civilDate: string): string => { + const [year, month, day] = civilDate.split('-').map(Number) + return ptBrWeekdays[new Date(Date.UTC(year, month - 1, day)).getUTCDay()] ?? '' +} diff --git a/tests/unit/activityAgendaInteractions.unit.spec.tsx b/tests/unit/activityAgendaInteractions.unit.spec.tsx index ce9655d5..d9b2f10b 100644 --- a/tests/unit/activityAgendaInteractions.unit.spec.tsx +++ b/tests/unit/activityAgendaInteractions.unit.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createElement, useEffect } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loadEvents: vi.fn(), @@ -44,7 +44,11 @@ type ScheduleMutationInfo = { } type CalendarProps = { - datesSet: (info: { startStr: string; endStr: string }) => void + datesSet: (info: { + startStr: string + endStr: string + view: { type: string; calendar: { getDate: () => Date } } + }) => void eventDrop: (info: ScheduleMutationInfo) => void eventResize: (info: ScheduleMutationInfo) => void dateClick: (info: DateClickInfo) => void @@ -57,6 +61,10 @@ vi.mock('@fullcalendar/react', () => { datesSet({ startStr: '2026-08-03T03:00:00.000Z', endStr: '2026-08-10T03:00:00.000Z', + view: { + type: 'timeGridWeek', + calendar: { getDate: () => new Date('2026-08-03T03:00:00.000Z') }, + }, }) }, [datesSet]) @@ -100,17 +108,34 @@ vi.mock('@fullcalendar/react', () => { }) import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda' +import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext' +import { stubMatchMedia } from '../helpers/matchMedia' + +const renderAgenda = (props: { state?: object } = {}) => + render( + + + , + ) describe('ActivityAgenda schedule failures', () => { + // C101 — ActivityAgenda reads the viewport signal (useIsMobileMeasured); + // jsdom has no matchMedia, so the harness pins the desktop layout (the + // toolbar and the calendar body are what these tests exercise). + beforeEach(() => { + stubMatchMedia() + }) + afterEach(() => { cleanup() vi.clearAllMocks() + vi.unstubAllGlobals() }) it('reverts a transport failure and releases the event for another attempt', async () => { mocks.loadEvents.mockResolvedValue([]) mocks.reschedule.mockRejectedValue(new Error('transport failed')) - render() + renderAgenda() await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled()) fireEvent.click(screen.getByRole('button', { name: 'Mover' })) @@ -126,7 +151,7 @@ describe('ActivityAgenda schedule failures', () => { it('reverts a refused resize with the safe server message', async () => { mocks.loadEvents.mockResolvedValue([]) mocks.reschedule.mockResolvedValue({ ok: false, message: 'Remarcação recusada.' }) - render() + renderAgenda() await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled()) fireEvent.click(screen.getByRole('button', { name: 'Redimensionar' })) @@ -137,7 +162,7 @@ describe('ActivityAgenda schedule failures', () => { it('abre a criação inline no clique de um slot vazio, sem navegar', async () => { mocks.loadEvents.mockResolvedValue([]) - render() + renderAgenda() await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled()) expect(mocks.routerPush).not.toHaveBeenCalled() @@ -153,7 +178,7 @@ describe('ActivityAgenda schedule failures', () => { it('não exibe o aviso de janela vazia nem o botão de criação acima do calendário', async () => { mocks.loadEvents.mockResolvedValue([]) - render() + renderAgenda() await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled()) diff --git a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts new file mode 100644 index 00000000..4a7bdaf1 --- /dev/null +++ b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' + +import { activityAgendaPeriodLabel, activityAgendaViewFromFcId } from '@/utilities/activityUi' + +describe('activityAgendaPeriodLabel', () => { + it('rotula o dia pelo dia e mês', () => { + expect( + activityAgendaPeriodLabel( + 'day', + '2026-08-09T00:00:00-03:00', + '2026-08-10T00:00:00-03:00', + '2026-08-09T00:00:00-03:00', + ), + ).toBe('9 Agosto') + }) + + it('rotula a semana pelo intervalo visível (segunda a domingo) no mesmo mês', () => { + expect( + activityAgendaPeriodLabel( + 'week', + '2026-08-03T00:00:00-03:00', + '2026-08-10T00:00:00-03:00', + '2026-08-03T00:00:00-03:00', + ), + ).toBe('3–9 Agosto') + }) + + it('escreve os dois meses quando a semana cruza o limite do mês', () => { + expect( + activityAgendaPeriodLabel( + 'week', + '2026-07-27T00:00:00-03:00', + '2026-08-03T00:00:00-03:00', + '2026-07-27T00:00:00-03:00', + ), + ).toBe('27 Julho – 2 Agosto') + }) + + it('cruza o ano sem omitir o dia final', () => { + expect( + activityAgendaPeriodLabel( + 'week', + '2026-12-28T00:00:00-03:00', + '2027-01-04T00:00:00-03:00', + '2026-12-28T00:00:00-03:00', + ), + ).toBe('28 Dezembro – 3 Janeiro') + }) + + it('rotula o mês pelo mês ativo, não pelo início do grid (domingo antecedente)', () => { + expect( + activityAgendaPeriodLabel( + 'month', + '2026-07-26T00:00:00-03:00', + '2026-09-01T00:00:00-03:00', + '2026-08-01', + ), + ).toBe('Agosto') + }) + + it('aceita o anchor como data civil pura (a forma do runtime, sem timezone)', () => { + // O runtime passa `formatBahiaCivilDate(getDate())` — "2026-09-01" sem + // offset. Parsear como instante UTC a deslocaria para 31 de agosto na + // Bahia (meia-noite UTC = 21:00 do dia anterior). + expect( + activityAgendaPeriodLabel( + 'month', + '2026-08-31T00:00:00-03:00', + '2026-10-01T00:00:00-03:00', + '2026-09-01', + ), + ).toBe('Setembro') + }) + + it('mantém "Agenda" na lista, onde os dias aparecem no corpo', () => { + expect( + activityAgendaPeriodLabel( + 'list', + '2026-08-01T00:00:00-03:00', + '2026-09-01T00:00:00-03:00', + '2026-08-01T00:00:00-03:00', + ), + ).toBe('Agenda') + }) + + it('retorna null para intervalo ilegível', () => { + expect( + activityAgendaPeriodLabel( + 'day', + 'não-é-data', + '2026-08-10T00:00:00-03:00', + '2026-08-09T00:00:00-03:00', + ), + ).toBeNull() + expect( + activityAgendaPeriodLabel( + 'week', + '2026-08-03T00:00:00-03:00', + 'não-é-data', + '2026-08-03T00:00:00-03:00', + ), + ).toBeNull() + expect( + activityAgendaPeriodLabel( + 'month', + '2026-08-01T00:00:00-03:00', + '2026-09-01T00:00:00-03:00', + 'não-é-data', + ), + ).toBeNull() + }) +}) + +describe('activityAgendaViewFromFcId', () => { + it('espelha activityAgendaViewFcId para as quatro vistas', () => { + expect(activityAgendaViewFromFcId('timeGridWeek')).toBe('week') + expect(activityAgendaViewFromFcId('timeGridDay')).toBe('day') + expect(activityAgendaViewFromFcId('dayGridMonth')).toBe('month') + expect(activityAgendaViewFromFcId('listMonth')).toBe('list') + }) + + it('retorna null para um view id desconhecido', () => { + expect(activityAgendaViewFromFcId('multiMonthYear')).toBeNull() + }) +}) diff --git a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx new file mode 100644 index 00000000..ca484b92 --- /dev/null +++ b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx @@ -0,0 +1,197 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { createElement, useRef, type MutableRefObject } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation' + +const pointerEvent = ( + type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel', + init: { + pointerId?: number + pointerType?: string + isPrimary?: boolean + clientX: number + clientY: number + }, +) => + new PointerEvent(type, { + pointerId: init.pointerId ?? 1, + pointerType: init.pointerType ?? 'touch', + isPrimary: init.isPrimary ?? true, + bubbles: true, + clientX: init.clientX, + clientY: init.clientY, + }) + +const Harness = ({ + enabled = true, + blockRef, + onSwipe, + onReady, +}: { + enabled?: boolean + blockRef?: MutableRefObject + onSwipe: (direction: 'next' | 'prev') => void + onReady?: (suppressDateClickRef: MutableRefObject) => void +}) => { + const containerRef = useRef(null) + const internalBlockRef = useRef(false) + const { suppressDateClickRef } = useAgendaSwipeNavigation({ + containerRef, + enabled, + blockRef: blockRef ?? internalBlockRef, + onSwipe, + }) + if (onReady) onReady(suppressDateClickRef) + return createElement('div', { ref: containerRef, 'data-testid': 'agenda' }) +} + +const swipe = (container: HTMLElement, fromX: number, toX: number, y = 100) => { + const agenda = container.firstElementChild as HTMLElement + fireEvent(agenda, pointerEvent('pointerdown', { clientX: fromX, clientY: y })) + fireEvent(window, pointerEvent('pointermove', { clientX: toX, clientY: y })) + fireEvent(window, pointerEvent('pointerup', { clientX: toX, clientY: y })) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useAgendaSwipeNavigation', () => { + it('navega para o período seguinte com um arrasto horizontal à esquerda', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + swipe(container as HTMLElement, 300, 100) + expect(onSwipe).toHaveBeenCalledWith('next') + }) + + it('navega para o período anterior com um arrasto horizontal à direita', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + swipe(container as HTMLElement, 100, 300) + expect(onSwipe).toHaveBeenCalledWith('prev') + }) + + it('não navega abaixo do limiar de 48px', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + swipe(container as HTMLElement, 300, 260) + expect(onSwipe).not.toHaveBeenCalled() + }) + + it('não navega num arrasto diagonal (dominância vertical)', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + fireEvent( + container.firstElementChild as HTMLElement, + pointerEvent('pointerdown', { clientX: 300, clientY: 100 }), + ) + fireEvent(window, pointerEvent('pointermove', { clientX: 200, clientY: 180 })) + expect(onSwipe).not.toHaveBeenCalled() + }) + + it('ignora mouse (a toolbar do desktop continua mandando)', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + fireEvent( + container, + pointerEvent('pointerdown', { pointerType: 'mouse', clientX: 300, clientY: 100 }), + ) + fireEvent( + window, + pointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, clientY: 100 }), + ) + expect(onSwipe).not.toHaveBeenCalled() + }) + + it('não inicia gesto com enabled=false', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { enabled: false, onSwipe })) + swipe(container as HTMLElement, 300, 100) + expect(onSwipe).not.toHaveBeenCalled() + }) + + it('abandona o gesto quando o FullCalendar começa um drag de evento (blockRef)', () => { + const onSwipe = vi.fn() + const blockRef = { current: false } + const { container } = render(createElement(Harness, { blockRef, onSwipe })) + fireEvent( + container.firstElementChild as HTMLElement, + pointerEvent('pointerdown', { clientX: 300, clientY: 100 }), + ) + // a flag vira true a meio do gesto (long-press do FC venceu) + blockRef.current = true + fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 })) + expect(onSwipe).not.toHaveBeenCalled() + }) + + it('marca suppressDateClickRef no consumo (o swipe não abre o inline create)', () => { + const onSwipe = vi.fn() + let suppressDateClickRef: MutableRefObject | undefined + const { container } = render( + createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }), + ) + expect(suppressDateClickRef).toBeDefined() + expect(suppressDateClickRef!.current).toBe(false) + swipe(container as HTMLElement, 300, 100) + expect(suppressDateClickRef!.current).toBe(true) + }) + + it('marca suppressDateClickRef já no claim de 12px (um flick sub-limiar não abre o inline create)', () => { + const onSwipe = vi.fn() + let suppressDateClickRef: MutableRefObject | undefined + const { container } = render( + createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }), + ) + const agenda = container.firstElementChild as HTMLElement + fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 })) + // flick de 20px: cruza o claim (12px) mas não o limiar de navegação + // (48px) — o claim vive no touchmove não-passivo. + fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] }) + expect(onSwipe).not.toHaveBeenCalled() + expect(suppressDateClickRef!.current).toBe(true) + }) + + it('limpa suppressDateClickRef no próximo pointerdown (o tap genuíno continua abrindo o create)', () => { + const onSwipe = vi.fn() + let suppressDateClickRef: MutableRefObject | undefined + const { container } = render( + createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }), + ) + const agenda = container.firstElementChild as HTMLElement + fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 })) + fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 })) + expect(suppressDateClickRef!.current).toBe(true) + fireEvent(agenda, pointerEvent('pointerdown', { clientX: 200, clientY: 100 })) + expect(suppressDateClickRef!.current).toBe(false) + }) + + it('aceita pen além de touch', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + const agenda = container.firstElementChild as HTMLElement + fireEvent( + agenda, + pointerEvent('pointerdown', { pointerType: 'pen', clientX: 300, clientY: 100 }), + ) + fireEvent( + window, + pointerEvent('pointermove', { pointerType: 'pen', clientX: 100, clientY: 100 }), + ) + expect(onSwipe).toHaveBeenCalledWith('next') + }) + + it('reseta o gesto no pointercancel e aceita o próximo swipe', () => { + const onSwipe = vi.fn() + const { container } = render(createElement(Harness, { onSwipe })) + const agenda = container.firstElementChild as HTMLElement + // o browser tomou o gesto (scroll vertical): pointercancel + fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 })) + fireEvent(window, pointerEvent('pointercancel', { clientX: 300, clientY: 180 })) + // o swipe seguinte funciona normalmente + fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 })) + fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 })) + expect(onSwipe).toHaveBeenCalledWith('next') + }) +})