Merge pull request #610 from fsolla/C110-agenda-mobile-feedback-visual-do-arrasto-reveal-do-peri

C110 — feedback visual do arrasto na agenda mobile (reveal do período adjacente + commit/snap-back)
This commit is contained in:
Francisco Solla 2026-08-10 15:02:53 -03:00 committed by GitHub
commit 4baa617c01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1343 additions and 133 deletions

View file

@ -0,0 +1,85 @@
# Impl: C110 — Agenda mobile: feedback visual do arrasto (reveal do período adjacente + commit/snap-back)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #589
Intenção: docs/plans/c110-agenda-mobile-feedback-arrasto.md
Appetite restante: ~1 dia eng (herdado; cabe nas fases abaixo sem corte)
## Leitura da intenção
- **Outcome:** durante o arrasto horizontal no calendário mobile (dia/semana/mês/lista), o grid **acompanha o dedo em tempo real** e o período adjacente é **revelado progressivamente** na direção do gesto (quadro do grid + cabeçalho com datas + chevron), com os **eventos do adjacente entrando no preview assíncronos** (degradação aceita: quadro sem eles, entram quando chegam). Soltar abaixo de 48 px → **snap-back** sem navegação; acima → **commit** com transição suave. `prefers-reduced-motion` → troca instantânea (como hoje).
- **O que NÃO negociar:** desktop intacto (chrome/toolbar atuais); tap de criação inline (C91) e long-press de remanejo (C15) intactos; vista de dia (coluna única) segue sem abrir criação após arrasto; contrato e2e CDP do gesto atualizado no mesmo item; sem segunda barra de controles; sem mudar o header (título continua atualizando só no commit).
- **O que reavaliar:** a hipótese da intenção de "expor o delta vivo do gesto" (decisão passa a ser no soltar) — **confirmado como o caminho**, mas com uma restrição de engenharia que a intenção não nomeia: o delta NÃO pode virar estado React por frame (re-render do FullCalendar a 60 fps é caro). O delta vivo vira transform imperativo no container, no próprio hook dono do gesto; o React só entra nos eventos discretos (preview liga/desliga).
## Abordagem recomendada
```mermaid
flowchart LR
A[useAgendaSwipeNavigation: claim 12px → touchcancel sintético + previewStart] --> B[transform imperativo no container]
B --> C[preview: painel filho left:100%/right:100% + chevron + label + frame + barras]
C --> D[onSwipePreviewEnd + onSwipe no soltar]
D --> E[commit ≥48px: transição dx→0 + api.next/prev]
D --> F[<48px: snap-back dx→0]
G[activityAgendaAdjacentPeriod puro] --> H[loadActivityAgendaEvents assíncrono + cache por direção]
H --> C
```
**Opções consideradas:** A | B | C | D | E
**Recomendação:** A — o hook continua dono do gesto (contrato unit-testado) e passa a aplicar o transform imperativo no container (zero re-render do FC por frame); o preview é um painel filho do container transformado em `left: 100%`/`right: 100%` (a costura do grid e do painel se move junto — a faixa revelada mostra a borda do painel sem coordenar dois elementos); a decisão (commit vs snap-back) acontece no `pointerup` com o limiar atual de 48 px.
**Rejeitadas:** B — segundo FullCalendar vivo para folhear de verdade (rabbit hole da intenção: render extra + chamada de dados por período); C — `swipeDx` em estado React atualizado por `pointermove` (re-render do FC por frame — 60 fps de reconciliação cara); D — CSS var `--swipe-dx` compartilhada entre container e painel (exige ref da shell, indireção sem ganho: o painel filho do container já herda o transform); E — manter commit no meio do gesto + só animar (o snap-back abaixo do limiar não existe se a troca já aconteceu — aceite exige decisão no soltar).
### Componentes / mudanças
- **`activityAgendaAdjacentPeriod`** (`src/utilities/activityUi.ts`): pura — `(view, { start, end, anchorDate }, direction) => { start, end, anchorDate } | null`. Espelha a aritmética do FC (dia ±1d; semana ±7d sobre os instantes — Bahia não tem DST, instante ± 86.400.000 ms é dia civil exato; mês/lista: anchor civil ±1 mês, range do mês = [segunda ≤ 1º, +42d] — cobre o grid de 5–6 semanas e fica ≤ cap de 45 dias do schema; label usa só o anchor). O label do preview sai do MESMO `activityAgendaPeriodLabel` sobre o range adjacente → garante label do preview == título pós-commit.
- **`useAgendaSwipeNavigation`** (`src/components/campaign/activity/useAgendaSwipeNavigation.ts`): contrato reworkado:
- `onSwipe(direction)` — agora dispara **só no commit** (`pointerup` com |dx| ≥ 48 e dominância), não mais no meio do gesto.
- `onSwipePreviewStart(direction)` — no claim (12 px + dominância, no `touchmove` não-passivo): direção **travada** aqui; dispara também o `touchcancel` sintético (movido do commit para o claim — mata o timer de long-press do FC no momento em que o gesto é nosso).
- `onSwipePreviewEnd()` — fim sem commit (solta < 48 px, dominância vertical, `blockRef`, `pointercancel`).
- Transform imperativo no `containerRef`: a partir do claim, `translateX(dx)` com **clamp na direção travada** (o grid nunca cruza o ponto de partida); snap-back/commit = classe de transição + reset para 0 (transitionend/fallback limpa). Reduz-movimento: a regra CSS existente zera a transição.
- **`ActivityAgenda.tsx`**: estado `swipePreview: { direction } | null` (liga no `previewStart`, desliga no `previewEnd`), `swipePreviewEvents` + cache `Map` por `start|end|direction` num ref (limpo quando `visibleRange` muda) + guard de chave ativa contra race de fetch; no previewStart: `loadActivityAgendaEvents({ ...state, rangeStart, rangeEnd })` com o range adjacente (mesma action/filtros do grid — access intacto). Commit: `api.next()/prev()` como hoje.
- **`ActivityAgendaSwipePreview.tsx`** (novo, `src/components/campaign/activity/`): painel `aria-hidden` filho do container transformado, `position: absolute; inset-block: 0; width: 100%; left: 100%` (next) / `right: 100%` (prev). Conteúdo na costura (lado do painel que encosta no grid): chevron (lucide), label do período adjacente, frame skeleton por vista (dia/semana: linha de cabeçalho com iniciais dos dias + separadores de coluna/faixa; mês: grid de células sutil; lista: label + linhas skeleton) e **barras de evento** aproximadas por proporção de tempo 07:00–22:00 (all-day → faixa do topo; mês → pontos por célula; lista → linhas com título). Aproximação assumida (preview ≠ pixel-perfect); fallback Opção 2 (só chevron+label) se destoar demais — guarda do aceite, validada no browser.
- **`ActivityAgenda.css`**: `.activity-agenda { position: relative }` (containing block do painel), painel/frame/barras, `will-change: transform` durante o drag (classe do hook), transição de saída, e o container entra na regra `prefers-reduced-motion` (hoje ela cobre só descendentes).
- **Migration:** nenhuma. **Access/Consent:** nenhum novo (mesma server action autenticada do grid).
### Dados → forma
- N/A para o grid (mesmo dado de hoje); a "forma" nova é o preview: quadro abstrato do grid (não números) + chevron + barras aproximadas — restrição de produto da intenção ("mostra o quadro do grid, não números").
## Fases verificáveis
1. **Helpers puros** — `activityAgendaAdjacentPeriod` + unit tests (dia/semana/mês cruzando ano, lista, ano bissexto, direção prev; paridade: `activityAgendaPeriodLabel(adjacente)` retorna o rótulo esperado pós-commit).
2. **Hook rework** — contrato novo + unit tests (claim → previewStart; solta < limiar → previewEnd sem onSwipe e transform resetado; ≥ limiar → onSwipe uma vez; dominância vertical → abandona + reset; blockRef; pointercancel; direção travada + clamp; pen; mouse ignorado; enabled=false; suppressDateClickRef inalterado: armado no claim, limpo no próximo pointerdown).
3. **Preview + integração (tracer)** — painel + frame + barras + cache/fetch; browser: transform segue o dedo, preview na faixa revelada, commit navega, snap-back volta.
4. **Impeccable C (shape → craft → critique → polish)** — localizado na superfície mobile da agenda: chevron (seguir canvas: aponta para o lado revelado; critique re-checa "direção da mudança" do aceite e pode flippar), frames por vista, barras, slide do commit, sensação do snap-back.
5. **E2E + gates** — adaptar `touchSwipe` (o commit agora é no soltar — o contrato de resultado é o mesmo), novos testes: snap-back (swipe < 48 px: título não muda, transform volta a zero), preview no meio do gesto (gesto pausado via CDP: painel + chevron visíveis; eventos do adjacente entram no preview com `expect.poll`), commit ≥ limiar; `pnpm gate:fast` na iteração; `pnpm push` na entrega.
## Rabbit holes / Não escopo (engenharia)
- Segundo FullCalendar vivo + preload (B da Opção D1 — o aceite cortou).
- Preview pixel-perfect (posição das barras é proporção aproximada; validar no browser se "destoa" → Opção 2).
- Reavaliação de direção no meio do gesto (travada no claim — nativo reavalia, mas aqui é cosmético e custaria fetch/render duplos; gatilho de revisita se o feel pedir).
- Animar o título do período no header (anti-goal da intenção — atualiza no commit, como hoje).
- Transições de criação inline / remanejo / redimensionamento; ano no rótulo do mês; desktop.
## Riscos e mitigação
- **dateClick do FC no grid deslocado** (vista de semana, pointerup sobre o grid antigo deslocado antes do `api.next()`): o click é engolido pela `suppressDateClickRef` já armada no claim (contrato existente; o e2e "swipe não abre inline create" pina).
- **FC com filho absoluto extra**: FC renderiza a própria raiz e não varre irmãos — verificar no browser (o painel é `aria-hidden` e não participa do layout).
- **Ordem touchend (FC) vs pointerup (janela)**: FC processa o toque antes do nosso commit; dateClick do grid antigo → engolido (acima). O `api.next()` no pointerup renderiza o período novo no container em transição → slide de entrada lido como transição suave; se o efeito ficar estranho, degrade para troca instantânea (aceite permite — "resultado final é o de hoje").
- **Mudança de comportamento documentada**: touchcancel no claim mata o timer de long-press do FC aos 12 px — um arrasto horizontal lento (12–47 px) sobre evento não inicia mais remanejo (hoje começava se passasse dos 650 ms antes dos 48 px). Remanejo (C15) continua intacto via hold-650ms-depois-arrasta (o `blockRef` vence antes do claim). Registrar como débito leve? Não — comportamento intencional e mais consistente (gesto horizontal = swipe).
- **Fetch durante o arrasto**: mesma action autenticada do grid; range ≤ 45d ✓; guard de chave ativa contra race; cache Map evita refetch ao ir e voltar.
- **Re-render**: transform imperativo no hook → FC não re-renderiza por frame; preview vira estado só nos eventos discretos.
- **E2E CDP**: observar o meio do gesto exige dispatches pausados (touchStart + moves → avaliar DOM → moves → touchEnd); o gesto pausado ~125 ms é idêntico ao padrão atual do helper.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (grid segue o dedo; adjacente revelado com chevron + label + quadro; eventos assíncronos no preview; snap-back < 48 px; commit ≥ 48 px com transição suave; reduced-motion instantâneo; desktop, tap C91 e long-press C15 intactos; vista de dia sem create pós-arrasto)
- [ ] Invariantes AGENTS/engineering-standards (sem migration/sem prod/sem Consent novo; copy pt-BR / identificadores em inglês; hook dono do gesto com contrato unit-testado; sem twin de componentes)
- [ ] Testes de domínio: unit do helper adjacente + label parity; unit do hook (contrato novo); e2e mobile (snap-back, preview mid-drag com eventos, commit) + existentes verdes; gate:fast e gate:push verdes
## Débitos deferidos (pós-/simplify; nenhum com score ≥4 — nada virou Issue)
- **Dots do mês ancorados no content box, não no frame do grid** (o painel posiciona os dots contra o bloco cheio do preview — chevron/label inclusos — e o frame é um skeleton independente). Polimento visual dentro da aproximação aceita pelo aceite; gatilho de revisita: se o mês ganhar mais uso no mobile ou a crítica de produto apontar o desalinhamento.
- **Gradiente de colunas duplicado no CSS** (base do frame + camada do mês) — custom property traria mais indireção que as ~5 linhas; descartado.
- **Chevron segue o canvas do gate (aponta para o lado revelado)** — se a leitura "direção da mudança" do aceite prevalecer na prática, é flip de 1 linha (ChevronLeft/Right) no `ActivityAgendaSwipePreview`.

View file

@ -45,6 +45,172 @@
event drag it preventDefaults window touchmoves (its touchScrollAllowed
cancellation), which stops the browser's pan claim. */
touch-action: pan-y;
/* C110 — the adjacent-period preview is positioned against the grid's own
edge (left:100% / right:100% of this container), which only works with a
containing block here. */
position: relative;
}
/* C110 — the live drag: the grid is directly manipulated by the finger (the
transform is imperative, written by the swipe hook — no React state at
60fps) and the release settles it with a short exit slide (commit) or the
snap-back. */
.activity-agenda.activity-agenda-swipe-dragging {
will-change: transform;
}
.activity-agenda.activity-agenda-swipe-exit {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* C110 — the adjacent-period reveal: a child of the transformed grid
container, glued to the seam. It rides the same transform as the grid (it
is a child), so the strip that opens at the grid's leading edge shows the
preview's seam edge — no dx bookkeeping in JS. */
.activity-agenda-swipe-preview {
position: absolute;
inset-block: 0;
width: 100%;
pointer-events: none;
overflow: hidden;
background: var(--card);
}
.activity-agenda-swipe-preview--next {
left: 100%;
border-left: 1px solid var(--border);
}
.activity-agenda-swipe-preview--prev {
right: 100%;
border-right: 1px solid var(--border);
}
.activity-agenda-swipe-preview-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem;
}
.activity-agenda-swipe-preview--prev .activity-agenda-swipe-preview-content {
align-items: flex-end;
text-align: right;
}
.activity-agenda-swipe-chevron {
width: 1.25rem;
height: 1.25rem;
color: var(--muted-foreground);
}
.activity-agenda-swipe-label {
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.2;
color: var(--muted-foreground);
white-space: nowrap;
}
/* The abstract grid frame ("quadro do grid"): a week/day header plus faint
column/time bands. Not a second FullCalendar — deliberately a skeleton. */
.activity-agenda-swipe-frame {
width: 100%;
flex: 1;
border: 1px solid var(--border);
border-radius: 0.375rem;
overflow: hidden;
opacity: 0.55;
background-image: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(14.285% - 1px),
var(--border) calc(14.285% - 1px),
var(--border) 14.285%
);
}
.activity-agenda-swipe-frame[data-view='day'] {
background-image: repeating-linear-gradient(
to bottom,
transparent 0,
transparent calc(20% - 1px),
var(--border) calc(20% - 1px),
var(--border) 20%
);
}
.activity-agenda-swipe-frame[data-view='month'] {
background-image:
repeating-linear-gradient(
to right,
transparent 0,
transparent calc(14.285% - 1px),
var(--border) calc(14.285% - 1px),
var(--border) 14.285%
),
repeating-linear-gradient(
to bottom,
transparent 0,
transparent calc(16.66% - 1px),
var(--border) calc(16.66% - 1px),
var(--border) 16.66%
);
}
.activity-agenda-swipe-frame-head {
display: flex;
border-bottom: 1px solid var(--border);
}
.activity-agenda-swipe-frame-head span {
flex: 1;
padding: 0.1875rem 0;
text-align: center;
font-size: 0.5625rem;
font-weight: 600;
color: var(--muted-foreground);
}
.activity-agenda-swipe-frame-head span + span {
border-left: 1px solid var(--border);
}
/* C110 — the adjacent period's events enter the preview as they load (the
accept's async path): bars by time proportion for day/week, dots per date
for month, rows for the list. Approximate on purpose — the commit renders
the real grid. */
.activity-agenda-swipe-event,
.activity-agenda-swipe-dot {
position: absolute;
border-radius: 0.1875rem;
background: color-mix(in oklab, var(--primary) 72%, transparent);
}
.activity-agenda-swipe-event {
left: 0;
width: 62%;
height: 0.3125rem;
}
.activity-agenda-swipe-dot {
width: 9%;
height: 0.375rem;
}
.activity-agenda-swipe-list-row {
display: block;
width: 100%;
overflow: hidden;
padding: 0.3125rem 0.375rem;
border-radius: 0.25rem;
background: color-mix(in oklab, var(--foreground) 7%, transparent);
font-size: 0.625rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-agenda button {
@ -220,6 +386,7 @@
}
@media (prefers-reduced-motion: reduce) {
.activity-agenda,
.activity-agenda *,
.activity-agenda *::before,
.activity-agenda *::after {

View file

@ -25,6 +25,10 @@ import {
loadActivityAgendaEvents,
rescheduleActivity,
} from '@/app/(campaign)/campanha/actions/activity'
import {
ActivityAgendaSwipePreview,
type ActivityAgendaAdjacentRange,
} from '@/components/campaign/activity/ActivityAgendaSwipePreview'
import {
ActivityInlineCreate,
type ActivityInlineCreateDraft,
@ -47,6 +51,7 @@ import {
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
activityAgendaAdjacentPeriod,
activityAgendaPeriodLabel,
activityAgendaViewFcId,
activityAgendaViewFromFcId,
@ -176,6 +181,12 @@ export const ActivityAgenda = ({
const [loadError, setLoadError] = useState<string | null>(null)
const [isNarrow, setIsNarrow] = useState(false)
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
// C110 — the adjacent-period reveal: which side is open and the events that
// load (async, during the drag) for that period.
const [swipePreview, setSwipePreview] = useState<{ direction: 'next' | 'prev' } | null>(null)
const [swipePreviewEvents, setSwipePreviewEvents] = useState<ActivityAgendaEvent[]>([])
const swipePreviewKeyRef = useRef<string | null>(null)
const adjacentEventsCacheRef = useRef(new Map<string, ActivityAgendaEvent[]>())
// 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
@ -302,6 +313,13 @@ export const ActivityAgenda = ({
)
}, [visibleRange])
// C110 — the adjacent range the open preview reveals (pure derivation from
// the visible range; null when the preview has no range to show).
const adjacentPreviewRange = useMemo<ActivityAgendaAdjacentRange | null>(() => {
if (!swipePreview || !visibleRange) return null
return activityAgendaAdjacentPeriod(visibleRange.view, visibleRange, swipePreview.direction)
}, [swipePreview, visibleRange])
const handleToday = useCallback(() => {
const api = calendarRef.current?.getApi()
if (!api) return
@ -344,11 +362,62 @@ export const ActivityAgenda = ({
onNavigate: handlePeriodNavigation,
})
// C110 — the preview opens at the 12px claim with the direction locked; the
// adjacent period's events load asynchronously (never blocking the gesture)
// with a per-visible-range cache so flicking back and forth does not refetch.
const handleSwipePreviewStart = useCallback(
(direction: 'next' | 'prev') => {
if (!visibleRange) return
const adjacent = activityAgendaAdjacentPeriod(visibleRange.view, visibleRange, direction)
if (!adjacent) return
const key = `${direction}:${visibleRange.start}|${visibleRange.end}`
swipePreviewKeyRef.current = key
setSwipePreview({ direction })
const cached = adjacentEventsCacheRef.current.get(key)
if (cached) {
setSwipePreviewEvents(cached)
return
}
setSwipePreviewEvents([])
void loadActivityAgendaEvents({
...state,
rangeStart: adjacent.start,
rangeEnd: adjacent.end,
})
.then((loadedEvents) => {
// C110 — only the ACTIVE preview may consume the result: a fetch from
// a previous gesture resolving late must not populate a preview for
// the other side (or after the preview already closed).
if (swipePreviewKeyRef.current !== key) return
adjacentEventsCacheRef.current.set(key, loadedEvents)
setSwipePreviewEvents(loadedEvents)
})
.catch(() => {
// C110 — the preview degrades to the empty frame; the commit still
// loads the real grid through the normal datesSet path.
})
},
[state, visibleRange],
)
const handleSwipePreviewEnd = useCallback(() => {
swipePreviewKeyRef.current = null
setSwipePreview(null)
}, [])
useEffect(() => {
// C110 — a commit/today invalidates the cache: its entries describe the
// previous visible range.
adjacentEventsCacheRef.current.clear()
}, [visibleRange])
const { suppressDateClickRef } = useAgendaSwipeNavigation({
containerRef,
enabled: isMobile,
blockRef: draggingEventRef,
onSwipe: handlePeriodNavigation,
onSwipePreviewStart: handleSwipePreviewStart,
onSwipePreviewEnd: handleSwipePreviewEnd,
})
const handleDateClick = useCallback(
@ -525,6 +594,17 @@ export const ActivityAgenda = ({
noEventsText="Nenhum compromisso nesta janela"
headingLevel={2}
/>
{/* `visibleRange &&` narrows the type for the preview's `view` prop
(the memo already guarantees it is non-null). */}
{swipePreview && visibleRange && adjacentPreviewRange ? (
<ActivityAgendaSwipePreview
view={visibleRange.view}
direction={swipePreview.direction}
range={adjacentPreviewRange}
events={swipePreviewEvents}
/>
) : null}
</div>
<ActivityInlineCreate

View file

@ -0,0 +1,134 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
civilDateDaysBetween,
formatBahiaCivilDate,
formatIsoAsBahiaDateTimeInput,
} from '@/lib/campaignTime'
import { activityAgendaPeriodLabel, type ActivityAgendaView } from '@/utilities/activityUi'
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
/**
* C110 — the adjacent-period reveal: while the finger drags the grid, this
* panel sits glued to the seam (a child of the transformed container, at
* `left: 100%` for next / `right: 100%` for prev) and its edge slides into
* the revealed strip. It shows the direction chevron, the adjacent period
* label and an abstract grid frame (the "quadro do grid" of the accept —
* deliberately NOT a second FullCalendar, which is a cut rabbit hole), with
* the adjacent period's events entering as bars/dots/rows as they load
* asynchronously. `aria-hidden`: the gesture is the interaction; the commit
* replaces the whole preview with the real grid.
*/
const SLOT_START_MINUTES = 7 * 60
const SLOT_END_MINUTES = 22 * 60
const SLOT_SPAN_MINUTES = SLOT_END_MINUTES - SLOT_START_MINUTES
const MAX_PREVIEW_EVENTS = 6
const weekInitials = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'] as const
export type ActivityAgendaAdjacentRange = {
start: string
end: string
anchorDate: string
}
const timedTopPercent = (startAt: string): number => {
const civil = formatIsoAsBahiaDateTimeInput(startAt)
const match = /T(\d{2}):(\d{2})/.exec(civil)
if (!match) return 0
const minutes = Number(match[1]) * 60 + Number(match[2])
const clamped = Math.min(Math.max(minutes - SLOT_START_MINUTES, 0), SLOT_SPAN_MINUTES)
return (clamped / SLOT_SPAN_MINUTES) * 100
}
const monthCellPosition = (
rangeStart: string,
startAt: string,
): { column: number; row: number } | null => {
const startCivil = formatBahiaCivilDate(new Date(rangeStart))
const eventCivil = formatBahiaCivilDate(new Date(startAt))
const dayIndex = civilDateDaysBetween(startCivil, eventCivil)
if (dayIndex < 0 || dayIndex > 41) return null
return { column: dayIndex % 7, row: Math.floor(dayIndex / 7) }
}
export const ActivityAgendaSwipePreview = ({
view,
direction,
range,
events,
}: {
view: ActivityAgendaView
direction: 'next' | 'prev'
range: ActivityAgendaAdjacentRange
events: ActivityAgendaEvent[]
}) => {
const label = activityAgendaPeriodLabel(view, range.start, range.end, range.anchorDate)
const seam =
direction === 'next'
? 'activity-agenda-swipe-preview--next'
: 'activity-agenda-swipe-preview--prev'
const visibleEvents = events.slice(0, MAX_PREVIEW_EVENTS)
return (
<div className={`activity-agenda-swipe-preview ${seam}`} aria-hidden="true">
<div className="activity-agenda-swipe-preview-content">
{direction === 'next' ? (
<ChevronRight className="activity-agenda-swipe-chevron" aria-hidden="true" />
) : (
<ChevronLeft className="activity-agenda-swipe-chevron" aria-hidden="true" />
)}
{label ? <span className="activity-agenda-swipe-label">{label}</span> : null}
{/* C110 — the abstract grid frame; the list view has no columns to
suggest, so it degrades to chevron + label + event rows (the
accepted degradation of the intention). The day view keeps just
the time bands — a single-day header would mislabel the columns. */}
{view !== 'list' ? (
<div className="activity-agenda-swipe-frame" data-view={view}>
{view === 'week' ? (
<div className="activity-agenda-swipe-frame-head">
{weekInitials.map((initial) => (
<span key={initial}>{initial}</span>
))}
</div>
) : null}
</div>
) : null}
{visibleEvents.map((event) => {
if (view === 'month') {
const cell = monthCellPosition(range.start, event.startAt)
if (!cell) return null
return (
<span
key={event.id}
className="activity-agenda-swipe-dot"
style={{
left: `${cell.column * (100 / 7)}%`,
top: `${cell.row * (100 / 6)}%`,
}}
/>
)
}
if (view === 'list') {
return (
<span key={event.id} className="activity-agenda-swipe-list-row">
{event.title}
</span>
)
}
return (
<span
key={event.id}
className="activity-agenda-swipe-event"
style={event.allDay ? { top: '2%' } : { top: `${timedTopPercent(event.startAt)}%` }}
/>
)
})}
</div>
</div>
)
}

View file

@ -3,8 +3,8 @@
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
* C101/C110 — 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
@ -17,6 +17,25 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject }
* preventDefault, so the timegrid keeps scrolling natively (and the browser
* cancels the pointer stream — the gesture then resets, which is correct).
*
* C110 — the gesture now gives live feedback instead of swapping mid-drag:
*
* - At the 12px claim the grid starts following the finger (imperative
* transform on the container — never React state, so FullCalendar does not
* re-render per frame), the adjacent period preview starts
* (`onSwipePreviewStart`), and a synthetic `touchcancel` is dispatched to
* 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 long-press timers. The direction is LOCKED at the claim and the
* transform is clamped to it (the grid never crosses its starting point).
* Every subsequent touchmove keeps calling `preventDefault`: the browser's
* pan/flick recognizers re-evaluate on each move, and one unprevented move
* mid-drag fires a real pointercancel (the stream dies, taking the live
* transform and the preview with it).
* - The navigation decision moved to the RELEASE: `pointerup` with
* |dx| ≥ 48px (and still horizontal) commits — `onSwipe(direction)` — with
* the grid sliding the remaining distance to rest; anything below commits
* as a snap-back (transform reset, no navigation, `onSwipePreviewEnd`).
*
* Contract with FullCalendar's touch interactions (FC runs on touch events,
* this hook on pointer events, both on the same gesture):
*
@ -30,9 +49,13 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject }
* 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.
* the synthetic `touchcancel` at the claim kills it as soon as the gesture
* is ours. A reschedule still wins when the user holds still first (the
* long-press fires before any claim and `blockRef` then abandons the
* swipe). Behavior change vs C101: a slow horizontal drag (12–47px) on an
* event no longer falls through to FC's long-press at 650ms — by then the
* claim has already canceled it — it snap-backs instead. Intentional: any
* claimed horizontal gesture is swipe territory.
* - A drag FC already owns (eventDragStart/eventResizeStart fired) is
* blocked via `blockRef`: slow horizontal drags on events stay
* reschedules.
@ -46,15 +69,31 @@ const AGENDA_SWIPE_DOMINANCE = 1.4
const AGENDA_SWIPE_CLAIM_PX = 12
/** Backstop for the dateClick suppression when no next gesture ever comes. */
const AGENDA_CLICK_SUPPRESSION_MS = 500
/** Exit animation (commit slide / snap-back), and its cleanup backstop. */
const AGENDA_SWIPE_EXIT_MS = 180
type AgendaSwipeDirection = 'next' | 'prev'
/** C110 — the grid's displacement is clamped to the claimed direction: it
* never crosses its starting point, and the release threshold reads this
* clamped position (the finger may have reversed past the origin — the grid
* did not move). */
const clampDisplacement = (dx: number, direction: AgendaSwipeDirection): number =>
direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
/** Swipe-vs-scroll discriminator: the vertical component dominates the drag. */
const isVerticalDrag = (dx: number, dy: number): boolean =>
Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
type AgendaSwipeGesture = {
pointerId: number
startX: number
startY: number
startTarget: EventTarget | null
consumed: boolean
/** Locked at the claim; drives the preview side and the transform clamp. */
direction: AgendaSwipeDirection | null
lastDx: number
lastDy: number
}
export const useAgendaSwipeNavigation = ({
@ -62,19 +101,29 @@ export const useAgendaSwipeNavigation = ({
enabled,
blockRef,
onSwipe,
onSwipePreviewStart,
onSwipePreviewEnd,
}: {
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>
/** C110 — fired only when the release COMMITS (|dx| ≥ threshold, horizontal). */
onSwipe: (direction: AgendaSwipeDirection) => void
/** C110 — the adjacent-period preview opens (claim) with the locked direction. */
onSwipePreviewStart?: (direction: AgendaSwipeDirection) => void
/** C110 — the preview closes without navigation (snap-back, abandon, cancel). */
onSwipePreviewEnd?: () => void
}): { suppressDateClickRef: MutableRefObject<boolean> } => {
const gestureRef = useRef<AgendaSwipeGesture | null>(null)
const enabledRef = useRef(enabled)
const blockRefRef = useRef(blockRef)
const onSwipeRef = useRef(onSwipe)
const onSwipePreviewStartRef = useRef(onSwipePreviewStart)
const onSwipePreviewEndRef = useRef(onSwipePreviewEnd)
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const exitTimerRef = 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 ===
@ -87,6 +136,8 @@ export const useAgendaSwipeNavigation = ({
enabledRef.current = enabled
blockRefRef.current = blockRef
onSwipeRef.current = onSwipe
onSwipePreviewStartRef.current = onSwipePreviewStart
onSwipePreviewEndRef.current = onSwipePreviewEnd
const armClickSuppression = useCallback(() => {
suppressDateClickRef.current = true
@ -97,75 +148,91 @@ export const useAgendaSwipeNavigation = ({
}, 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
const clearPendingExit = useCallback(() => {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current)
exitTimerRef.current = null
}
const container = containerRef.current
if (!container) return
container.classList.remove('activity-agenda-swipe-dragging', 'activity-agenda-swipe-exit')
}, [containerRef])
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()
}
/** C110 — the live transform: follows the finger, clamped to the claimed
* direction (the grid never crosses its starting point). */
const applyTransform = useCallback(
(dx: number, direction: AgendaSwipeDirection) => {
const container = containerRef.current
if (!container) return
const clamped = clampDisplacement(dx, direction)
container.style.transform = clamped === 0 ? '' : `translateX(${clamped}px)`
},
[armClickSuppression],
[containerRef],
)
const handlePointerMove = useCallback(
/** C110 — release: animate the grid back to rest (commit slide or snap-back). */
const settleTransform = useCallback(() => {
const container = containerRef.current
if (!container) return
container.classList.remove('activity-agenda-swipe-dragging')
container.classList.add('activity-agenda-swipe-exit')
container.style.transform = ''
if (exitTimerRef.current) clearTimeout(exitTimerRef.current)
exitTimerRef.current = setTimeout(() => {
exitTimerRef.current = null
container.classList.remove('activity-agenda-swipe-exit')
}, AGENDA_SWIPE_EXIT_MS + 100)
}, [containerRef])
/** C110 — end a claimed gesture without committing: preview closes, grid settles. */
const endWithoutCommit = useCallback(() => {
settleTransform()
onSwipePreviewEndRef.current?.()
}, [settleTransform])
const handlePointerDown = useCallback(
(event: PointerEvent) => {
const gesture = gestureRef.current
if (!gesture || gesture.consumed || event.pointerId !== gesture.pointerId) return
if (blockRefRef.current.current) {
gestureRef.current = null
return
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.
// Any in-flight exit animation belongs to the previous gesture — the
// new one owns the transform from here.
suppressDateClickRef.current = false
if (suppressTimerRef.current) {
clearTimeout(suppressTimerRef.current)
suppressTimerRef.current = null
}
clearPendingExit()
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
gestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startTarget: event.target,
direction: null,
lastDx: 0,
lastDy: 0,
}
},
[clearPendingExit],
)
gesture.consumed = true
/** C110 — the 12px claim lives in the non-passive touchmove (the same spot
* the preventDefault must happen): lock the direction, start the preview,
* arm the click suppression and kill FullCalendar's pending timers. */
const claimGesture = useCallback(
(gesture: AgendaSwipeGesture, dx: number, dy: number) => {
gesture.direction = dx < 0 ? 'next' : 'prev'
gesture.lastDx = dx
gesture.lastDy = dy
armClickSuppression()
// 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
// Kill FullCalendar's pending long-press/tap timers: 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) {
@ -174,17 +241,115 @@ export const useAgendaSwipeNavigation = ({
)
}
armClickSuppression()
onSwipeRef.current(deltaX < 0 ? 'next' : 'prev')
containerRef.current?.classList.add('activity-agenda-swipe-dragging')
applyTransform(dx, gesture.direction)
onSwipePreviewStartRef.current?.(gesture.direction)
},
[armClickSuppression],
[applyTransform, armClickSuppression, containerRef],
)
const handlePointerEnd = useCallback((event: PointerEvent) => {
const gesture = gestureRef.current
if (!gesture || event.pointerId !== gesture.pointerId) return
gestureRef.current = null
}, [])
const handleTouchMove = useCallback(
(event: TouchEvent) => {
const gesture = gestureRef.current
if (!gesture || blockRefRef.current.current) return
const touch = event.touches[0]
if (!touch) return
const dx = touch.clientX - gesture.startX
const dy = touch.clientY - gesture.startY
if (gesture.direction === null) {
if (Math.abs(dx) > AGENDA_SWIPE_CLAIM_PX && !isVerticalDrag(dx, dy)) {
// 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()
claimGesture(gesture, dx, dy)
}
return
}
// C110 — already claimed: keep holding the gesture. The preventDefault
// must repeat on EVERY move, not just the claim: the browser's
// pan/flick recognizers re-evaluate on each touchmove, and an
// unprevented move mid-drag fires a real pointercancel — killing the
// live transform and the preview. (C101 got away with claim-only
// preventDefault because its commit ended the gesture within a move or
// two; the release decision keeps the gesture alive through many.)
event.preventDefault()
gesture.lastDx = dx
gesture.lastDy = dy
applyTransform(dx, gesture.direction)
},
[applyTransform, claimGesture],
)
const handlePointerMove = useCallback(
(event: PointerEvent) => {
const gesture = gestureRef.current
if (!gesture || event.pointerId !== gesture.pointerId) return
if (blockRefRef.current.current) {
if (gesture.direction !== null) endWithoutCommit()
gestureRef.current = null
return
}
const dx = event.clientX - gesture.startX
const dy = event.clientY - gesture.startY
gesture.lastDx = dx
gesture.lastDy = dy
if (gesture.direction === null) {
if (isVerticalDrag(dx, dy)) {
// Vertical drag (scrolling the timeline, rescheduling): never claim.
gestureRef.current = null
}
return
}
if (isVerticalDrag(dx, dy)) {
// C110 — a claimed gesture turned vertical (scroll takeover): abandon,
// snap back, close the preview. The browser is scrolling now — the
// grid must not stay displaced.
gestureRef.current = null
endWithoutCommit()
return
}
applyTransform(dx, gesture.direction)
},
[applyTransform, endWithoutCommit],
)
const handlePointerEnd = useCallback(
(event: PointerEvent) => {
const gesture = gestureRef.current
if (!gesture || event.pointerId !== gesture.pointerId) return
gestureRef.current = null
if (event.type === 'pointercancel') {
if (gesture.direction !== null) endWithoutCommit()
return
}
// C110 — the decision is the RELEASE: commit when the drag is far and
// still horizontal, snap back otherwise. The threshold measures the
// grid's ACTUAL displacement (the clamp-locked position the finger
// left the grid at), not the raw delta — a gesture that reversed past
// its origin released an undisplaced grid and must snap back.
if (gesture.direction !== null) {
const dx = gesture.lastDx
const dy = gesture.lastDy
const displaced = clampDisplacement(dx, gesture.direction)
if (Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX && !isVerticalDrag(dx, dy)) {
endWithoutCommit()
onSwipeRef.current(gesture.direction)
return
}
endWithoutCommit()
}
},
[endWithoutCommit],
)
useEffect(() => {
const container = containerRef.current
@ -213,6 +378,7 @@ export const useAgendaSwipeNavigation = ({
useEffect(
() => () => {
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current)
if (exitTimerRef.current) clearTimeout(exitTimerRef.current)
},
[],
)

View file

@ -94,7 +94,84 @@ const ptBrMonthNames = [
'Dezembro',
] as const
const civilDateParts = (civilDate: string): { day: number; month: number } | null => {
/**
* C110 — the period the swipe preview reveals, mirroring FullCalendar's own
* navigation arithmetic so the preview label (derived from this range via
* `activityAgendaPeriodLabel`) matches the header title after the commit:
* day shifts ±1 day, week ±7 days (Bahia has no DST, so instant ± 86.400.000 ms
* is an exact civil day), month/list shift the anchor (a civil date of the
* month's 1st) by one month. The month range spans the Monday on or before the
* 1st through 6 full weeks (42 days — within the agenda's 45-day request cap,
* which is all the adjacent fetch needs); the label only reads the anchor.
* Returns null when the range cannot be derived (same fail-closed contract as
* `activityAgendaPeriodLabel`).
*/
export const activityAgendaAdjacentPeriod = (
view: ActivityAgendaView,
range: { start: string; end: string; anchorDate: string },
direction: 'next' | 'prev',
): { start: string; end: string; anchorDate: string } | null => {
const shift = direction === 'next' ? 1 : -1
const shiftRangeByDays = (days: number) => {
const start = new Date(range.start)
const end = new Date(range.end)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null
const shiftedStart = new Date(start.getTime() + days * 86_400_000)
return {
start: shiftedStart.toISOString(),
end: new Date(end.getTime() + days * 86_400_000).toISOString(),
anchorDate: formatBahiaCivilDate(shiftedStart),
}
}
if (view === 'day' || view === 'week') {
return shiftRangeByDays(shift * (view === 'day' ? 1 : 7))
}
const anchor = civilDateParts(range.anchorDate)
if (!anchor || anchor.day !== 1) return null
const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shift)
const shiftedAnchor = `${shiftedAnchorParts.year}-${pad(shiftedAnchorParts.month)}-01`
if (view === 'list') {
const nextMonth = shiftCivilMonth(shiftedAnchorParts.year, shiftedAnchorParts.month, 1)
return {
start: allDayStartInstant(shiftedAnchor),
end: allDayStartInstant(`${nextMonth.year}-${pad(nextMonth.month)}-01`),
anchorDate: shiftedAnchor,
}
}
// month — the grid starts on the Monday (firstDay=1) on or before the 1st
// and spans at most 6 weeks.
const start = subtractBahiaCivilDays(shiftedAnchor, mondayOffsetOf(shiftedAnchor))
return {
start: allDayStartInstant(start),
end: allDayStartInstant(subtractBahiaCivilDays(start, -42)),
anchorDate: shiftedAnchor,
}
}
const pad = (value: number): string => String(value).padStart(2, '0')
const shiftCivilMonth = (
year: number,
month: number,
months: number,
): { year: number; month: number } => {
const absolute = year * 12 + (month - 1) + months
return { year: Math.floor(absolute / 12), month: (absolute % 12) + 1 }
}
/** Weekday of a civil date counted from Monday (0 = Monday … 6 = Sunday). */
const mondayOffsetOf = (civilDate: string): number => {
const [year, month, day] = civilDate.split('-').map(Number)
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
return (weekday + 6) % 7
}
const civilDateParts = (civilDate: string): { year: number; month: number; day: number } | null => {
const [year, month, day] = civilDate.split('-').map(Number)
if (
!Number.isInteger(year) ||
@ -107,7 +184,7 @@ const civilDateParts = (civilDate: string): { day: number; month: number } | nul
) {
return null
}
return { day, month }
return { year, month, day }
}
const civilDateLabel = (civilDate: string): string | null => {

View file

@ -43,12 +43,19 @@ const openMonthView = async (page: Page) => {
* `/campanha`. Paced, it stays a drag the agenda consumes via its
* non-passive touchmove preventDefault, while still finishing far below
* FullCalendar's 650ms long-press (which would start a reschedule).
*
* C110 — `inspectAfterMove` pauses the gesture mid-drag (after the 12px
* claim) so a test can observe the live preview before the release decides.
*/
const touchSwipe = async (
const dispatchTouchDrag = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
{
moveDelayMs = 25,
inspectAfterMove,
}: { moveDelayMs?: number; inspectAfterMove?: () => Promise<void> } = {},
) => {
const y = Math.round(box.y + box.height * 0.5)
const x0 = Math.round(box.x + box.width * fromRatio)
@ -57,36 +64,63 @@ const touchSwipe = async (
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 new Promise((resolve) => setTimeout(resolve, 25))
await new Promise((resolve) => setTimeout(resolve, moveDelayMs))
if (i === 2) await inspectAfterMove?.()
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
const touchSwipe = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
) => dispatchTouchDrag(cdp, box, fromRatio, toRatio)
const agendaSwipePreview = (page: Page) => page.locator('.activity-agenda-swipe-preview')
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> => {
/**
* Seeds agenda activities and logs the coordinator in. `rows` maps a civil
* date + hour to an activity title; `fixtures.value` keeps titles/slugs
* unique per test run — literal titles would collide on the unique slug
* between consecutive tests of this spec.
*/
const seedAgendaActivities = async (
campaign: CampaignE2EFixture,
page: Page,
rows: Array<[civilDate: string, hour: string, title: string]>,
): Promise<void> => {
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,
})
for (const [civilDate, hour, rawTitle] of rows) {
const startAt = parseBahiaDateTimeInput(`${civilDate}T${hour}`)
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(rawTitle),
tags: ['Comício'],
status: 'confirmado',
startAt,
municipality: municipality.id,
}),
depth: 0,
})
}
await campaign.login(page, coordinator.email!, coordinator.password)
}
const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise<string> => {
const civilDate = formatBahiaCivilDate(new Date())
await seedAgendaActivities(campaign, page, [[civilDate, '10:00', 'Compromisso mobile nativo']])
return civilDate
}
@ -347,3 +381,117 @@ test.describe('C101 — agenda mobile com cara de app nativo', () => {
await expect(page.getByRole('dialog')).toHaveCount(0)
})
})
test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit/snap-back)', () => {
test.setTimeout(90_000)
const seedTodayAndTomorrowActivities = async (
campaign: CampaignE2EFixture,
page: Page,
): Promise<{ today: string; tomorrow: string }> => {
const today = formatBahiaCivilDate(new Date())
const tomorrow = civilDatePlusDays(today, 1)
await seedAgendaActivities(campaign, page, [
[today, '10:00', 'Compromisso mobile hoje'],
[tomorrow, '11:00', 'Compromisso mobile amanhã'],
])
return { today, tomorrow }
}
test('o grid segue o dedo, o período adjacente é revelado e o soltar decide (commit)', async ({
campaign,
page,
}) => {
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const todayLabel = dayLabelFor(today)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
const cdp = await page.context().newCDPSession(page)
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.')
// A meio do gesto (após o claim de 12px): o grid acompanhou o dedo e o
// painel do período adjacente está revelado na borda — chevron, rótulo do
// período seguinte e os eventos do adjacente entrando conforme carregam.
await dispatchTouchDrag(cdp, agendaBox, 0.8, 0.3, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('svg.activity-agenda-swipe-chevron')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
await expect
.poll(() => preview.locator('.activity-agenda-swipe-event').count(), {
timeout: 15_000,
})
.toBeGreaterThan(0)
// O grid deslocado: o transform vivo acompanhou o dedo.
const transform = await page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
)
expect(transform).not.toBe('none')
},
})
// Soltou acima do limiar: o período adjacente assume a tela e o preview some.
await expect(campaignPageChrome(page, dayLabelFor(tomorrow))).toBeVisible({
timeout: 15_000,
})
await expect(agendaSwipePreview(page)).toHaveCount(0)
// O commit não abre o inline create.
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('soltar abaixo do limiar volta ao período atual (snap-back) sem navegar', async ({
campaign,
page,
}) => {
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const todayLabel = dayLabelFor(today)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
const cdp = await page.context().newCDPSession(page)
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.')
// Arrasto de ~47px (0.9 → 0.78 em 390px): cruza o claim de 12px — o
// preview abre — mas o soltar fica abaixo dos 48px do limiar.
await dispatchTouchDrag(cdp, agendaBox, 0.9, 0.78, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
},
})
// Snap-back: o título não muda e o grid voltou ao repouso.
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
await expect(agendaSwipePreview(page)).toHaveCount(0)
await expect
.poll(
() =>
page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
),
{ timeout: 5_000 },
)
.toBe('none')
// Nenhum inline create após o snap-back.
await expect(page.getByRole('dialog')).toHaveCount(0)
})
})

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { activityAgendaPeriodLabel, activityAgendaViewFromFcId } from '@/utilities/activityUi'
import {
activityAgendaAdjacentPeriod,
activityAgendaPeriodLabel,
activityAgendaViewFromFcId,
} from '@/utilities/activityUi'
describe('activityAgendaPeriodLabel', () => {
it('rotula o dia pelo dia e mês', () => {
@ -111,6 +115,210 @@ describe('activityAgendaPeriodLabel', () => {
})
})
describe('activityAgendaAdjacentPeriod', () => {
it('desloca o dia em ±1 dia e ancora no dia civil deslocado', () => {
expect(
activityAgendaAdjacentPeriod(
'day',
{
start: '2026-08-09T00:00:00-03:00',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-09',
},
'next',
),
).toEqual({
start: '2026-08-10T03:00:00.000Z',
end: '2026-08-11T03:00:00.000Z',
anchorDate: '2026-08-10',
})
// paridade: o label do preview (range adjacente) == título pós-commit
expect(
activityAgendaPeriodLabel(
'day',
'2026-08-10T03:00:00.000Z',
'2026-08-11T03:00:00.000Z',
'2026-08-10',
),
).toBe('10 Agosto')
expect(
activityAgendaAdjacentPeriod(
'day',
{
start: '2026-08-09T00:00:00-03:00',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-09',
},
'prev',
),
).toEqual({
start: '2026-08-08T03:00:00.000Z',
end: '2026-08-09T03:00:00.000Z',
anchorDate: '2026-08-08',
})
})
it('desloca a semana em ±7 dias preservando segunda a domingo', () => {
expect(
activityAgendaAdjacentPeriod(
'week',
{
start: '2026-08-03T00:00:00-03:00',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-03',
},
'next',
),
).toEqual({
start: '2026-08-10T03:00:00.000Z',
end: '2026-08-17T03:00:00.000Z',
anchorDate: '2026-08-10',
})
// paridade: o label do preview == título pós-commit
expect(
activityAgendaPeriodLabel(
'week',
'2026-08-10T03:00:00.000Z',
'2026-08-17T03:00:00.000Z',
'2026-08-10',
),
).toBe('10–16 Agosto')
})
it('cruza o ano na semana e o dia (28 Dezembro → 4 Janeiro)', () => {
const weekRange = {
start: '2026-12-28T00:00:00-03:00',
end: '2027-01-04T00:00:00-03:00',
anchorDate: '2026-12-28',
}
const next = activityAgendaAdjacentPeriod('week', weekRange, 'next')
expect(next).toEqual({
start: '2027-01-04T03:00:00.000Z',
end: '2027-01-11T03:00:00.000Z',
anchorDate: '2027-01-04',
})
expect(activityAgendaPeriodLabel('week', next!.start, next!.end, next!.anchorDate)).toBe(
'4–10 Janeiro',
)
})
it('desloca o mês a partir do anchor civil (1º do mês) com range de 42 dias', () => {
const next = activityAgendaAdjacentPeriod(
'month',
{
start: '2026-08-31T00:00:00-03:00',
end: '2026-10-01T00:00:00-03:00',
anchorDate: '2026-09-01',
},
'next',
)
// Outubro/2026: 1º é quinta → segunda que precede = 28 Setembro.
expect(next).toEqual({
start: '2026-09-28T03:00:00.000Z',
end: '2026-11-09T03:00:00.000Z',
anchorDate: '2026-10-01',
})
expect(activityAgendaPeriodLabel('month', next!.start, next!.end, next!.anchorDate)).toBe(
'Outubro',
)
})
it('desloca o mês cruzando o ano e ajusta o grid à segunda antecedente', () => {
const prev = activityAgendaAdjacentPeriod(
'month',
{
start: '2026-12-28T00:00:00-03:00',
end: '2027-02-01T00:00:00-03:00',
anchorDate: '2027-01-01',
},
'prev',
)
// Dezembro/2026: 1º é terça → segunda antecedente = 30 Novembro.
expect(prev).toEqual({
start: '2026-11-30T03:00:00.000Z',
end: '2027-01-11T03:00:00.000Z',
anchorDate: '2026-12-01',
})
expect(activityAgendaPeriodLabel('month', prev!.start, prev!.end, prev!.anchorDate)).toBe(
'Dezembro',
)
})
it('lida com ano bissexto na virada do mês (Fevereiro 2028)', () => {
const next = activityAgendaAdjacentPeriod(
'month',
{
start: '2028-01-31T00:00:00-03:00',
end: '2028-03-01T00:00:00-03:00',
anchorDate: '2028-02-01',
},
'next',
)
// Março/2028: 1º é quarta → segunda antecedente = 28 Fevereiro.
expect(next).toEqual({
start: '2028-02-28T03:00:00.000Z',
end: '2028-04-10T03:00:00.000Z',
anchorDate: '2028-03-01',
})
})
it('desloca a lista em um mês inteiro (range do mês adjacente)', () => {
const next = activityAgendaAdjacentPeriod(
'list',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-01',
},
'next',
)
expect(next).toEqual({
start: '2026-09-01T03:00:00.000Z',
end: '2026-10-01T03:00:00.000Z',
anchorDate: '2026-09-01',
})
expect(activityAgendaPeriodLabel('list', next!.start, next!.end, next!.anchorDate)).toBe(
'Agenda',
)
})
it('retorna null quando o anchor do mês não é o 1º, é malformado, ou o intervalo é ilegível', () => {
expect(
activityAgendaAdjacentPeriod(
'month',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-15',
},
'next',
),
).toBeNull()
expect(
activityAgendaAdjacentPeriod(
'month',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: 'não-é-data',
},
'next',
),
).toBeNull()
expect(
activityAgendaAdjacentPeriod(
'day',
{
start: 'não-é-data',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-09',
},
'next',
),
).toBeNull()
})
})
describe('activityAgendaViewFromFcId', () => {
it('espelha activityAgendaViewFcId para as quatro vistas', () => {
expect(activityAgendaViewFromFcId('timeGridWeek')).toBe('week')

View file

@ -27,11 +27,15 @@ const Harness = ({
enabled = true,
blockRef,
onSwipe,
onSwipePreviewStart,
onSwipePreviewEnd,
onReady,
}: {
enabled?: boolean
blockRef?: MutableRefObject<boolean>
onSwipe: (direction: 'next' | 'prev') => void
onSwipePreviewStart?: (direction: 'next' | 'prev') => void
onSwipePreviewEnd?: () => void
onReady?: (suppressDateClickRef: MutableRefObject<boolean>) => void
}) => {
const containerRef = useRef<HTMLDivElement>(null)
@ -41,16 +45,41 @@ const Harness = ({
enabled,
blockRef: blockRef ?? internalBlockRef,
onSwipe,
onSwipePreviewStart,
onSwipePreviewEnd,
})
if (onReady) onReady(suppressDateClickRef)
return createElement('div', { ref: containerRef, 'data-testid': 'agenda' })
}
const agendaOf = (container: HTMLElement): HTMLElement => container.firstElementChild as HTMLElement
const swipe = (container: HTMLElement, fromX: number, toX: number, y = 100) => {
const agenda = container.firstElementChild as HTMLElement
/** Dispatches a cancellable touchmove and returns the event for assertion. */
const touchMoveAt = (agenda: HTMLElement, x: number, y: number): Event => {
const event = new Event('touchmove', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', {
value: [{ clientX: x, clientY: y }],
})
agenda.dispatchEvent(event)
return event
}
const claim = (agenda: HTMLElement, fromX: number, toX: number, y = 100) => {
// C110 — the claim lives in the non-passive touchmove: pointerdown, then a
// touchmove past the 12px dead zone (dominance horizontal), then the
// pointermove that keeps the transform fresh.
fireEvent(agenda, pointerEvent('pointerdown', { clientX: fromX, clientY: y }))
fireEvent.touchMove(agenda, { touches: [{ clientX: toX, clientY: y }] })
fireEvent(window, pointerEvent('pointermove', { clientX: toX, clientY: y }))
fireEvent(window, pointerEvent('pointerup', { clientX: toX, clientY: y }))
}
const release = (_agenda: HTMLElement, x: number, y = 100) => {
fireEvent(window, pointerEvent('pointerup', { clientX: x, clientY: y }))
}
const dragAndRelease = (container: HTMLElement, fromX: number, toX: number, y = 100) => {
const agenda = agendaOf(container)
claim(agenda, fromX, toX, y)
release(agenda, toX, y)
}
afterEach(() => {
@ -59,28 +88,122 @@ afterEach(() => {
})
describe('useAgendaSwipeNavigation', () => {
it('navega para o período seguinte com um arrasto horizontal à esquerda', () => {
it('commita o período seguinte ao soltar acima do limiar de 48px', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
swipe(container as HTMLElement, 300, 100)
const onSwipePreviewStart = vi.fn()
const onSwipePreviewEnd = vi.fn()
const { container } = render(
createElement(Harness, { onSwipe, onSwipePreviewStart, onSwipePreviewEnd }),
)
dragAndRelease(container as HTMLElement, 300, 100)
expect(onSwipe).toHaveBeenCalledTimes(1)
expect(onSwipe).toHaveBeenCalledWith('next')
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
})
it('navega para o período anterior com um arrasto horizontal à direita', () => {
it('commita o período anterior ao soltar à direita', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
swipe(container as HTMLElement, 100, 300)
dragAndRelease(container as HTMLElement, 100, 300)
expect(onSwipe).toHaveBeenCalledWith('prev')
})
it('não navega abaixo do limiar de 48px', () => {
it('não navega abaixo do limiar: snap-back sem onSwipe', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
swipe(container as HTMLElement, 300, 260)
const onSwipePreviewEnd = vi.fn()
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
dragAndRelease(container as HTMLElement, 300, 260)
expect(onSwipe).not.toHaveBeenCalled()
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
})
it('não abre preview nem transforma o grid abaixo do claim de 12px', () => {
const onSwipe = vi.fn()
const onSwipePreviewStart = vi.fn()
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewStart }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 291, clientY: 100 }] })
fireEvent(window, pointerEvent('pointermove', { clientX: 291, clientY: 100 }))
release(agenda, 291)
expect(onSwipePreviewStart).not.toHaveBeenCalled()
expect(onSwipe).not.toHaveBeenCalled()
expect(agenda.style.transform).toBe('')
})
it('abre o preview e transforma o grid no claim; o grid segue o dedo', () => {
const onSwipePreviewStart = vi.fn()
const { container } = render(createElement(Harness, { onSwipe: vi.fn(), onSwipePreviewStart }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 285, clientY: 100 }] })
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
expect(agenda.classList.contains('activity-agenda-swipe-dragging')).toBe(true)
expect(agenda.style.transform).toBe('translateX(-15px)')
// o dedo continua: o transform acompanha (200px de deslocamento total)
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
expect(agenda.style.transform).toBe('translateX(-200px)')
release(agenda, 100)
})
it('preventDefault em todo touchmove claimado (segura o gesto do browser)', () => {
const { container } = render(createElement(Harness, { onSwipe: vi.fn() }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
// o claim preventDefault o primeiro move horizontal
const claimEvent = touchMoveAt(agenda, 285, 100)
expect(claimEvent.defaultPrevented).toBe(true)
// e CADA move seguinte também — o pan controller reavalia por move e um
// move não-prevented dispara pointercancel real, matando o gesto vivo
const nextMove = touchMoveAt(agenda, 200, 100)
expect(nextMove.defaultPrevented).toBe(true)
const lastMove = touchMoveAt(agenda, 150, 100)
expect(lastMove.defaultPrevented).toBe(true)
release(agenda, 150)
})
it('não preventDefault em touchmove vertical (o scroll nativo continua)', () => {
const { container } = render(createElement(Harness, { onSwipe: vi.fn() }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
const verticalMove = touchMoveAt(agenda, 300, 180)
expect(verticalMove.defaultPrevented).toBe(false)
})
it('trava a direção no claim: reverter não vira o preview nem cruza a origem', () => {
const onSwipePreviewStart = vi.fn()
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewStart }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
expect(onSwipePreviewStart).toHaveBeenCalledWith('next')
// reversão para +100px: clamp na direção 'next' — o grid nunca cruza a origem
fireEvent(window, pointerEvent('pointermove', { clientX: 400, clientY: 100 }))
expect(agenda.style.transform).toBe('')
// soltar em +100 não commita (deslocamento final é 0 no domínio travado)
release(agenda, 400)
expect(onSwipe).not.toHaveBeenCalled()
})
it('não navega num arrasto diagonal (dominância vertical)', () => {
it('abandona e volta quando um gesto claimado vira vertical (dominância)', () => {
const onSwipe = vi.fn()
const onSwipePreviewEnd = vi.fn()
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
expect(agenda.style.transform).toBe('translateX(-20px)')
// vertical 3:1 sobre o horizontal — o scroll tomou o gesto
fireEvent(window, pointerEvent('pointermove', { clientX: 280, clientY: 400 }))
expect(onSwipe).not.toHaveBeenCalled()
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
expect(agenda.style.transform).toBe('')
expect(agenda.classList.contains('activity-agenda-swipe-exit')).toBe(true)
})
it('não navega num arrasto diagonal antes do claim (dominância vertical)', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
fireEvent(
@ -91,6 +214,18 @@ describe('useAgendaSwipeNavigation', () => {
expect(onSwipe).not.toHaveBeenCalled()
})
it('não commita no soltar se o deslocamento final virou vertical', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 250, clientY: 100 }] })
// deslocamento final: dx=-50 (acima do limiar) mas dy=-80 (dominância vertical)
fireEvent(window, pointerEvent('pointermove', { clientX: 250, clientY: 180 }))
release(agenda, 250, 180)
expect(onSwipe).not.toHaveBeenCalled()
})
it('ignora mouse (a toolbar do desktop continua mandando)', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
@ -107,26 +242,47 @@ describe('useAgendaSwipeNavigation', () => {
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)
const onSwipePreviewStart = vi.fn()
const { container } = render(
createElement(Harness, { enabled: false, onSwipe, onSwipePreviewStart }),
)
dragAndRelease(container as HTMLElement, 300, 100)
expect(onSwipe).not.toHaveBeenCalled()
expect(onSwipePreviewStart).not.toHaveBeenCalled()
})
it('abandona o gesto quando o FullCalendar começa um drag de evento (blockRef)', () => {
const onSwipe = vi.fn()
const onSwipePreviewEnd = vi.fn()
const blockRef = { current: false }
const { container } = render(createElement(Harness, { blockRef, onSwipe }))
fireEvent(
container.firstElementChild as HTMLElement,
pointerEvent('pointerdown', { clientX: 300, clientY: 100 }),
)
const { container } = render(createElement(Harness, { blockRef, onSwipe, onSwipePreviewEnd }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, 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()
expect(onSwipePreviewEnd).not.toHaveBeenCalled()
})
it('marca suppressDateClickRef no consumo (o swipe não abre o inline create)', () => {
it('fecha o preview e volta no pointercancel depois do claim', () => {
const onSwipe = vi.fn()
const onSwipePreviewEnd = vi.fn()
const { container } = render(createElement(Harness, { onSwipe, onSwipePreviewEnd }))
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 250, clientY: 100 }] })
// o browser tomou o gesto (scroll vertical): pointercancel
fireEvent(window, pointerEvent('pointercancel', { clientX: 250, clientY: 180 }))
expect(onSwipe).not.toHaveBeenCalled()
expect(onSwipePreviewEnd).toHaveBeenCalledTimes(1)
expect(agenda.style.transform).toBe('')
// o swipe seguinte funciona normalmente
dragAndRelease(container as HTMLElement, 300, 100)
expect(onSwipe).toHaveBeenCalledWith('next')
})
it('marca suppressDateClickRef no claim (o swipe não abre o inline create)', () => {
const onSwipe = vi.fn()
let suppressDateClickRef: MutableRefObject<boolean> | undefined
const { container } = render(
@ -134,20 +290,9 @@ describe('useAgendaSwipeNavigation', () => {
)
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
const agenda = agendaOf(container 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.
// flick de 20px: cruza o claim (12px) mas não o limiar de navegação (48px)
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
expect(onSwipe).not.toHaveBeenCalled()
expect(suppressDateClickRef!.current).toBe(true)
@ -159,9 +304,9 @@ describe('useAgendaSwipeNavigation', () => {
const { container } = render(
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
)
const agenda = container.firstElementChild as HTMLElement
const agenda = agendaOf(container as HTMLElement)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
expect(suppressDateClickRef!.current).toBe(true)
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 200, clientY: 100 }))
expect(suppressDateClickRef!.current).toBe(false)
@ -170,28 +315,28 @@ describe('useAgendaSwipeNavigation', () => {
it('aceita pen além de touch', () => {
const onSwipe = vi.fn()
const { container } = render(createElement(Harness, { onSwipe }))
const agenda = container.firstElementChild as HTMLElement
const agenda = agendaOf(container as HTMLElement)
fireEvent(
agenda,
pointerEvent('pointerdown', { pointerType: 'pen', clientX: 300, clientY: 100 }),
)
// pen gera touch events no Chrome — o claim vive no touchmove como no touch
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
fireEvent(
window,
pointerEvent('pointermove', { pointerType: 'pen', clientX: 100, clientY: 100 }),
)
release(agenda, 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
const agenda = agendaOf(container as HTMLElement)
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 }))
dragAndRelease(container as HTMLElement, 300, 100)
expect(onSwipe).toHaveBeenCalledWith('next')
})
})