feat(C101): agenda mobile com cara de app nativo — contexto no header, swipe, filtro sticky, dia na hora atual

- toolbar do FullCalendar some no mobile; título do header mostra o período
  ("9 Agosto", "3–9 Agosto", "Agosto", "Agenda") e tap volta ao hoje
- navegação por arrasto horizontal (useAgendaSwipeNavigation): limiar 48px,
  claim 12px com preventDefault, touchcancel sintético contra o long-press
  do FC, dateClick do dia suprimido (C91/C15 intactos)
- filtro vira strip edge-to-edge sem rótulo, sticky sob a barra do app
  (containing block via :has no padding do scroll container)
- visão de dia com scroll interno (height fixa), 08:00 na abertura,
  cabeçalho do dia fixo, scroll preservado ao trocar de dia
- desktop inalterado; impl plan + plano pós-critique (C101-ux)
This commit is contained in:
Francisco Solla 2026-08-09 22:09:47 -03:00
parent c3e447f135
commit 51fb715ec7
17 changed files with 1431 additions and 92 deletions

View file

@ -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 `<button>` (mantém truncate/flex; `title="Voltar para hoje"`; texto continua sendo o accessible name).
- **`ActivityAgendaFilters.tsx`**: envolve a omnibox em `<div className="activity-agenda-filter-strip">`.
- **`ActivityAgenda.css`**: bloco `@media (max-width: 767px)`:
- strip: `margin-inline: -1rem; margin-top: -1rem; margin-bottom: -1.5rem` (edge-to-edge, colada na top bar e no topo do calendário), `position: sticky; top: 0; z-index: 20; background: var(--background)` (branco sólido do tema campaign); label virado `sr-only` (mantém accessible name do combobox).
- `.activity-agenda { height: … }` para o `height='100%'` liquid (tune no browser; alvo ≈ `calc(100svh - 11rem)`).
- **Migration:** nenhuma (só UI/estado de tela). Nenhum `Consent`/access novo.
### Dados → forma
- Forma escolhida: rótulo de período em texto no header do app (título da página) — "o que estou olhando" num olhar, sem UI nova. Rejeitadas: chip/segunda linha no header (vira segunda barra — anti-goal); título custom dentro do calendário (duplicaria o contexto).
## Fases verificáveis
1. **Tracer (schema+server é nulo aqui) → mecânica do calendário**: label pura + unit tests; props condicionais (toolbar/height/scrollTimeReset) + verificação no browser (scroll interno, col-header fixo, 08:00, `scrollTimeReset=false` preservando scroll). Ajuste do `height` calc no browser.
2. **UI**: `AgendaPeriodChrome` + `onTitleClick` no chrome compartilhado; strip CSS edge-to-edge/sticky; gesto de swipe + `touchcancel` sintético. Impeccable C: shape → craft → critique → polish localizado na superfície mobile da agenda.
3. **Gates**: `pnpm gate:fast` na iteração; `pnpm push` na entrega. E2E: atualizar asserções mobile do C95 (toolbar some, título mostra o período) + novos testes C101 (swipe por CDP touch, tap no título volta ao hoje, strip sticky/sem label, dia abre em 08:00 com cabeçalho fixo).
## Rabbit holes / Não escopo (engenharia)
- Animação do swipe / transição suave entre períodos (FC7 não tem; corte: troca instantânea — prefer motion-reduce).
- Ano no rótulo (mês "Agosto" cruzando ano fica ambíguo — corte: sem ano, como o aceite; registrar débito leve).
- `touch-action` no container (proibido: `pan-y` quebraria o arrasto vertical de remanejo; o gesto horizontal não precisa — não há scroll horizontal na página).
- Re-centrar dinamicamente na "hora atual" real (o aceite fixa 08:00; registrar como opção futura).
- Gesto no desktop e arrasto com mouse; hamburger/sidebar (C102); mudar omnibox das outras listas.
## Riscos e mitigação
- **Height liquid do FC7**: `height='100%'` + CSS no `.activity-agenda` — verificar no browser; fallback `contentHeight` em px só se o liquid não fechar (com tune por breakpoint).
- **Conflito gesto × long-press do FC** (650 ms): swipe rápido em cima de evento — `touchcancel` sintético mata o timer do EventDragging; swipe lento (>650 ms) — `eventDragStart` já setou o guard → não navega. Janela residual coberta: o consumo exige 48 px com dominância; sem movimento, sem consumo.
- **`TouchEvent` ausente em jsdom/unit**: guard `typeof TouchEvent !== 'undefined'` (browser tem; o unit test do hook cobre o disparo do `onSwipe`, não o cancel).
- **Overflow horizontal**: o strip usa margens negativas idênticas ao `.activity-agenda-shell` (que já é edge-to-edge sem overflow) — o e2e existente (`scrollWidth <= innerWidth`) protege.
- **Breakpoint 768–800 com sidebar**: `isMobile` é janela (<768) — a faixa desktop 768–800 com container estreito mantém o chrome desktop (toolbar visível, sem override, sem swipe). Comportamento existente do C95 (day view por container) intacto.
- **E2E swipe via CDP**: `Input.dispatchTouchEvent` (precedente: WebAuthn usa CDP); swipe rápido em slot vazio (fixture cria 1 evento às 10:00 — alvo acima dele) para nunca cruzar a janela do long-press.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (toolbar some no mobile, título por período, swipe, strip sticky, 08:00, "domingo 9" fixo, desktop intacto)
- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem prod, sem Consent novo, copy pt-BR / ids em inglês)
- [ ] Testes de domínio: unit do label (day/week mês-cruzado/list/inválido) + hook do gesto (limiar/dominância/pointerType/guarda); e2e mobile novos + C95 ajustado; gate:fast e gate:push verdes

View file

@ -0,0 +1,46 @@
# Impl: Agenda mobile — affordance do "voltar ao hoje" + navegação por teclado (pós-C101)
Status: rascunho
Atualizado em: 2026-08-09
Issue: #C101-ux
Intenção: docs/plans/c101-agenda-mobile-calendario-nativo.md
Appetite: ~0,5 dia eng (fill-in pós-entrega)
## Leitura do lote
- **Outcome:** no mobile da agenda, (1) usuários de teclado/AT conseguem navegar entre períodos mesmo com a toolbar do FullCalendar escondida (C101), e (2) o tap no título do header ("voltar ao hoje") tem uma pista visual — hoje só existe `title` (hover), invisível no toque.
- **O que NÃO negociar:** desktop intacto (toolbar continua mandando lá); swipe continua sendo o gesto primário; C91 (tap cria inline) e C15 (arrasto de remanejo) intactos; nada de segunda barra de controles.
- **O que reavaliar:** se o FullCalendar já entrega navegação por setas no grid focado (FC tem seleção por teclado interna) — talvez baste expor/focar o grid, sem handler próprio.
## Fases verificáveis
1. **a11y (primeiro):** teclado no mobile — ArrowLeft/ArrowRight navegam dia/semana/mês conforme a vista. Investigar o teclado interno do FC antes de criar handler próprio; se FC já navega com o grid focado, garantir que o foco chegue lá (o container tem `tabIndex`?). Teste unitário + e2e com `page.keyboard`.
2. **UX:** pista visual do tap-no-título — opções: chip "Hoje" discreto ao lado do título no top bar (só na agenda mobile) | ícone de calendário | nada além do affordance de botão. Validar no gate (Impeccable B/C).
3. **Gates:** `pnpm gate:fast` na iteração; `pnpm push` na entrega.
## Rabbit holes / Não escopo
- Redesenhar a top bar; mudar o swipe; segunda barra; desktop; outras listas.
## Riscos e mitigação
- Handler próprio de teclado pode conflitar com o teclado interno do FC (seleção de célula) — verificar primeiro; se conflitar, restringir o handler a quando o foco está fora do grid.
- Chip "Hoje" no top bar disputa espaço com [Semana ▾][sino] — medir no browser antes.
## Já resolvido no simplify (não reabrir)
- Contrato `onTitleClick: { action, hint }` no chrome compartilhado (copy da agenda não vaza para o componente genérico).
- Helper compartilhado de rótulos de período (`tests/e2e/helpers/agendaPeriodLabels.ts`).
- Sticky real do strip; suppress do dateClick no claim; guarda de resize; anchor civil do mês (incl. bug de parse UTC).
- Robustez do e2e (poll, tolerâncias, scroller ancorado, stubMatchMedia).
## Explicitamente fora (descartes + defers com gatilho deste triage)
- **Defer:** validação do interplay pan-y × pointercancel em device real — gatilho: antes de qualquer evolução do gesto de swipe (o e2e CDP cobre o caminho Chromium, não o Safari/WebKit).
- Descartados: variável CSS do budget de altura (P3); flash de hidratação do título (padrão B167 existente); nota de meia-noite nos e2e (documentada); side-effect do harness de teste (P3).
## Aceite de engenharia
- [ ] Teclado navega períodos no mobile sem conflitar com a seleção interna do FC
- [ ] Tap no título comunica a ação sem depender de hover
- [ ] Desktop, swipe, C91 e C15 intactos; gates verdes

View file

@ -37,6 +37,14 @@
padding: 1rem;
font-size: 0.875rem;
/* C101 — the horizontal swipe (navigation) must see pointermove events:
without a touch-action the browser claims any touch drag for panning and
fires pointercancel at the first move, killing the gesture before the
threshold. pan-y keeps vertical scrolling native while horizontal
movements stay with the page. FullCalendar's own drags survive: during an
event drag it preventDefaults window touchmoves (its touchScrollAllowed
cancellation), which stops the browser's pan claim. */
touch-action: pan-y;
}
.activity-agenda button {
@ -121,6 +129,47 @@
text-decoration: line-through;
}
/* C101 — the agenda's filter strip. The wrapper class lives in
ActivityAgendaFilters (agenda-only); the shared omnibox keeps its desktop
look everywhere else. Mobile: edge-to-edge, label visually hidden (still
the combobox's accessible name), sticky under the app top bar and flush
against the calendar card.
Sticky geometry: the wrapper's containing block must start flush under the
app bar, otherwise the sticky clamp pins it 1rem lower. The app scroll
container's top padding is therefore zeroed on the agenda page (scoped via
`:has`), so the page shell — and with it the strip's containing block —
starts at the top bar's bottom edge. */
.activity-agenda-filter-strip {
min-width: 0;
}
@media (max-width: 767px) {
[data-slot='campaign-content-scroll']:has(.activity-agenda-filter-strip) {
padding-top: 0;
}
.activity-agenda-filter-strip {
position: sticky;
top: 0;
z-index: 20;
margin-inline: -1rem;
margin-bottom: -1.5rem;
background: var(--background);
}
.activity-agenda-filter-strip label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
@media (max-width: 767px) {
.activity-agenda-shell {
/* Edge-to-edge on phones: break out of the p-4 scroll padding and drop
@ -131,6 +180,11 @@
}
.activity-agenda {
/* C101 — the day/week/month grid becomes a self-contained scroller on
phones (FullCalendar `height="100%"`), so the day header stays fixed
while the hours scroll and `scrollTime`/`scrollToTime` can anchor the
current hour. Budget: top bar + strip + bottom nav + page padding. */
height: calc(100svh - 12rem);
padding: 0.5rem 0;
font-size: 0.75rem;
}

View file

@ -18,7 +18,7 @@ import themePlugin from '@fullcalendar/react/themes/classic'
import '@fullcalendar/react/themes/classic/theme.css'
import timeGridPlugin from '@fullcalendar/react/timegrid'
import { UserRoundCheckIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
@ -29,17 +29,24 @@ import {
ActivityInlineCreate,
type ActivityInlineCreateDraft,
} from '@/components/campaign/activity/ActivityInlineCreate'
import { AgendaPeriodChrome } from '@/components/campaign/activity/AgendaPeriodChrome'
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { useIsMobileMeasured } from '@/hooks/use-mobile'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import {
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
activityStatusLabels,
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
activityAgendaPeriodLabel,
activityAgendaViewFcId,
activityAgendaViewFromFcId,
activitySlotPrefill,
type ActivityAgendaState,
type ActivityAgendaView,
} from '@/utilities/activityUi'
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
@ -63,6 +70,15 @@ const eventColors: Record<ActivityStatus, { color: string; contrastColor: string
const MOBILE_BREAKPOINT_PX = 640
/**
* C101 — the desktop chrome the FullCalendar toolbar keeps: title + prev/next
* + today. Mobile replaces it with the app header (period title + swipe).
*/
const desktopToolbarConfig = { start: 'prev,next today', center: 'title' } as const
/** C101 — the fixed scroll anchor the day view opens at ("hoje é fixa em 08:00"). */
const AGENDA_DAY_SCROLL_TIME = '08:00:00'
const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
id: String(event.id),
title: event.title,
@ -116,7 +132,13 @@ export const ActivityAgenda = ({
const narrowRef = useRef<boolean | null>(null)
const mountedRef = useRef(false)
const pendingEventIDs = useRef(new Set<string>())
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null)
const draggingEventRef = useRef(false)
const [visibleRange, setVisibleRange] = useState<{
start: string
end: string
anchorDate: string
view: ActivityAgendaView
} | null>(null)
const [events, setEvents] = useState<EventInput[]>([])
const [reloadCount, setReloadCount] = useState(0)
const [isLoading, setIsLoading] = useState(true)
@ -125,6 +147,13 @@ export const ActivityAgenda = ({
const [isNarrow, setIsNarrow] = useState(false)
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
// C101 — the mobile behaviors key on the WINDOW (<768, the app top bar's
// own `md:hidden` breakpoint), not on the calendar container: a desktop
// window with a narrow content pane (768–800 with the sidebar open) keeps
// the desktop chrome. The container-based `isNarrow` keeps deciding the
// responsive day/week fallback, as before C101.
const { isMobile } = useIsMobileMeasured()
useEffect(() => {
mountedRef.current = true
return () => {
@ -210,14 +239,103 @@ export const ActivityAgenda = ({
}
}, [reloadCount, state, visibleRange])
const handleDatesSet = useCallback(({ startStr, endStr }: DatesSetInfo) => {
const handleDatesSet = useCallback(({ startStr, endStr, view }: DatesSetInfo) => {
const nextView = activityAgendaViewFromFcId(view.type)
if (!nextView) return
// The month grid's visible range starts on the leading Sunday of the
// previous month — the period label anchors on the calendar's current
// date (the 1st of the displayed month), which is what the title must
// claim. Civil date (not the ISO instant): the anchor is only the
// day/month of the label, and instants near midnight Bahia must not
// churn the range identity.
const anchorDate = formatBahiaCivilDate(view.calendar.getDate())
setVisibleRange((current) =>
current?.start === startStr && current.end === endStr
current?.start === startStr &&
current.end === endStr &&
current.view === nextView &&
current.anchorDate === anchorDate
? current
: { start: startStr, end: endStr },
: { start: startStr, end: endStr, anchorDate, view: nextView },
)
}, [])
// C101 — the mobile header period context: derived from the range the
// calendar actually shows (so it follows swipe navigation and view changes),
// rendered only when the mobile top bar is the visible chrome.
const periodLabel = useMemo(() => {
if (!visibleRange) return null
return activityAgendaPeriodLabel(
visibleRange.view,
visibleRange.start,
visibleRange.end,
visibleRange.anchorDate,
)
}, [visibleRange])
const handleToday = useCallback(() => {
const api = calendarRef.current?.getApi()
if (!api) return
api.today()
// C101 rabbit-hole cut: re-center on the way back to today (the day view
// opens at 08:00), but never fight the user's scroll on day changes.
api.scrollToTime(AGENDA_DAY_SCROLL_TIME)
}, [])
const handleEventDragStart = useCallback(() => {
draggingEventRef.current = true
}, [])
const handleEventDragStop = useCallback(() => {
draggingEventRef.current = false
}, [])
const handleEventResizeStart = useCallback(() => {
draggingEventRef.current = true
}, [])
const handleEventResizeStop = useCallback(() => {
draggingEventRef.current = false
}, [])
const handleSwipeNavigation = useCallback((direction: 'next' | 'prev') => {
const api = calendarRef.current?.getApi()
if (!api) return
if (direction === 'next') api.next()
else api.prev()
}, [])
const { suppressDateClickRef } = useAgendaSwipeNavigation({
containerRef,
enabled: isMobile,
blockRef: draggingEventRef,
onSwipe: handleSwipeNavigation,
})
const handleDateClick = useCallback(
(info: DateClickInfo) => {
// C101 — a consumed swipe ends with FullCalendar's dateClick in the day
// view (single column: the hit never leaves the starting slot). The
// navigation already happened — swallow the click so it does not open
// the inline create.
if (suppressDateClickRef.current) return
const prefill = activitySlotPrefill(info)
if (!prefill) return
const jsEvent = info.jsEvent as MouseEvent | undefined
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
const anchor = hasPoint
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
: (() => {
const rect = containerRef.current?.getBoundingClientRect()
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
})()
setCreateDraft({ ...prefill, anchor })
},
[suppressDateClickRef],
)
const persistSchedule = useCallback(
async ({
id,
@ -277,22 +395,6 @@ export const ActivityAgenda = ({
[persistSchedule],
)
const handleDateClick = useCallback((info: DateClickInfo) => {
const prefill = activitySlotPrefill(info)
if (!prefill) return
const jsEvent = info.jsEvent as MouseEvent | undefined
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
const anchor = hasPoint
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
: (() => {
const rect = containerRef.current?.getBoundingClientRect()
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
})()
setCreateDraft({ ...prefill, anchor })
}, [])
return (
<section className="activity-agenda-shell" aria-label="Calendário de atividades">
<div className="sr-only" aria-live="polite">
@ -320,6 +422,8 @@ export const ActivityAgenda = ({
</div>
) : null}
<AgendaPeriodChrome label={isMobile ? periodLabel : null} onToday={handleToday} />
<div className="activity-agenda" aria-busy={isLoading || savingCount > 0} ref={containerRef}>
<FullCalendar
ref={calendarRef}
@ -328,14 +432,12 @@ export const ActivityAgenda = ({
timeZone="America/Bahia"
firstDay={1}
initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}
headerToolbar={{
start: 'prev,next today',
center: 'title',
}}
height="auto"
headerToolbar={isMobile ? false : desktopToolbarConfig}
height={isMobile ? '100%' : 'auto'}
scrollTimeReset={!isMobile}
slotMinTime="07:00:00"
slotMaxTime="22:00:00"
scrollTime="08:00:00"
scrollTime={AGENDA_DAY_SCROLL_TIME}
nowIndicator
allDaySlot={false}
dayMaxEvents
@ -347,6 +449,10 @@ export const ActivityAgenda = ({
dateClick={handleDateClick}
eventDrop={handleScheduleChange}
eventResize={handleScheduleChange}
eventDragStart={handleEventDragStart}
eventDragStop={handleEventDragStop}
eventResizeStart={handleEventResizeStart}
eventResizeStop={handleEventResizeStop}
noEventsText="Nenhum compromisso nesta janela"
headingLevel={2}
/>

View file

@ -105,51 +105,53 @@ export const ActivityAgendaFilters = ({
}
return (
<CampaignListOmnibox
id="agenda-omnibox"
label="Filtrar agenda"
placeholder="Filtrar por município, tag, deputado presente…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
}}
onClearAll={() => {
runAction(clearActivityAgendaOmnibox())
}}
trailing={
<div className="hidden items-center gap-2 md:flex">
<Button
asChild
variant="outline"
size="icon"
className="min-h-11 min-w-11"
aria-label="Planejar giro"
title="Planejar giro"
>
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
<MapPinnedIcon className="size-5" aria-hidden />
</Link>
</Button>
<Button
asChild
size="icon"
className="min-h-11 min-w-11"
aria-label="Nova atividade"
title="Nova atividade"
>
<Link href={buildActivityCreateHref(state)}>
<PlusIcon className="size-5" aria-hidden />
</Link>
</Button>
</div>
}
/>
<div className="activity-agenda-filter-strip">
<CampaignListOmnibox
id="agenda-omnibox"
label="Filtrar agenda"
placeholder="Filtrar por município, tag, deputado presente…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
}}
onClearAll={() => {
runAction(clearActivityAgendaOmnibox())
}}
trailing={
<div className="hidden items-center gap-2 md:flex">
<Button
asChild
variant="outline"
size="icon"
className="min-h-11 min-w-11"
aria-label="Planejar giro"
title="Planejar giro"
>
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
<MapPinnedIcon className="size-5" aria-hidden />
</Link>
</Button>
<Button
asChild
size="icon"
className="min-h-11 min-w-11"
aria-label="Nova atividade"
title="Nova atividade"
>
<Link href={buildActivityCreateHref(state)}>
<PlusIcon className="size-5" aria-hidden />
</Link>
</Button>
</div>
}
/>
</div>
)
}

View file

@ -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 <SetCampaignPageChrome chrome={chrome} />
}

View file

@ -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<HTMLElement | null>
/** 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<boolean>
onSwipe: (direction: AgendaSwipeDirection) => void
}): { suppressDateClickRef: MutableRefObject<boolean> } => {
const gestureRef = useRef<AgendaSwipeGesture | null>(null)
const enabledRef = useRef(enabled)
const blockRefRef = useRef(blockRef)
const onSwipeRef = useRef(onSwipe)
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | 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 }
}

View file

@ -26,9 +26,24 @@ export const CampaignPageChromeText = ({
return (
<div data-slot="campaign-page-chrome" className={cn('min-w-0 flex-1 leading-tight', className)}>
<span data-slot="campaign-page-chrome-title" className="block truncate text-sm font-semibold">
{chrome.title}
</span>
{chrome.onTitleClick ? (
<button
type="button"
data-slot="campaign-page-chrome-title"
title={chrome.onTitleClick.hint}
onClick={chrome.onTitleClick.action}
className="block max-w-full truncate text-left text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{chrome.title}
</button>
) : (
<span
data-slot="campaign-page-chrome-title"
className="block truncate text-sm font-semibold"
>
{chrome.title}
</span>
)}
{chrome.subtitle ? (
<span className="block truncate text-xs text-primary-foreground/80">{chrome.subtitle}</span>
) : null}

View file

@ -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']

View file

@ -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<ActivityAgendaView, string> = {
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)

View file

@ -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()
})
})

View file

@ -0,0 +1,265 @@
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<string> => {
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.
const agendaBox = await page.locator('.activity-agenda').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")...
await expect
.poll(
() =>
page.evaluate(() => {
const scroller = [...document.querySelectorAll('.activity-agenda *')].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.querySelectorAll('.activity-agenda [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.querySelectorAll('.activity-agenda *')].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')
})
})

View file

@ -516,7 +516,7 @@ export const expectCampaignBiometricsReady = async (page: Page): Promise<void> =
}).toPass({ timeout: 15_000 })
}
type CampaignE2EFixture = {
export type CampaignE2EFixture = {
baseURL: string
fixtures: CampaignE2EOwnership
login: (page: Page, identifier: string, password: string) => Promise<void>

View file

@ -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()] ?? ''
}

View file

@ -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(
<CampaignPageChromeProvider role="coordinator">
<ActivityAgenda state={props.state ?? {}} />
</CampaignPageChromeProvider>,
)
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(<ActivityAgenda state={{}} />)
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(<ActivityAgenda state={{}} />)
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(<ActivityAgenda state={{}} />)
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(<ActivityAgenda state={{}} />)
renderAgenda()
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())

View file

@ -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()
})
})

View file

@ -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<boolean>
onSwipe: (direction: 'next' | 'prev') => void
onReady?: (suppressDateClickRef: MutableRefObject<boolean>) => void
}) => {
const containerRef = useRef<HTMLDivElement>(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<boolean> | 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<boolean> | 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<boolean> | 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')
})
})