From b23a323f83d8d1985bff4472e182394cb471c1fd Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 13:51:49 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(C110):=20feedback=20visual=20do=20arra?= =?UTF-8?q?sto=20na=20agenda=20mobile=20=E2=80=94=20reveal=20do=20per?= =?UTF-8?q?=C3=ADodo=20adjacente=20+=20commit/snap-back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...110-agenda-mobile-feedback-arrasto-impl.md | 79 +++++ .../campaign/activity/ActivityAgenda.css | 173 ++++++++++ .../campaign/activity/ActivityAgenda.tsx | 79 +++++ .../activity/ActivityAgendaSwipePreview.tsx | 135 ++++++++ .../activity/useAgendaSwipeNavigation.ts | 304 +++++++++++++----- src/utilities/activityUi.ts | 106 ++++++ tests/e2e/campaignAgendaMobile.e2e.spec.ts | 153 +++++++++ .../activityAgendaPeriodLabel.unit.spec.ts | 233 +++++++++++++- .../useAgendaSwipeNavigation.unit.spec.tsx | 228 ++++++++++--- 9 files changed, 1376 insertions(+), 114 deletions(-) create mode 100644 docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md create mode 100644 src/components/campaign/activity/ActivityAgendaSwipePreview.tsx diff --git a/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md new file mode 100644 index 00000000..3ba5f72d --- /dev/null +++ b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md @@ -0,0 +1,79 @@ +# 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 diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css index 6a424a18..30854dd4 100644 --- a/src/components/campaign/activity/ActivityAgenda.css +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -45,6 +45,178 @@ 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--next .activity-agenda-swipe-preview-content { + align-items: flex-start; + text-align: left; +} + +.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% + ); + background-size: 100% 100%; + position: relative; +} + +.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 { + position: absolute; + left: 0; + width: 62%; + height: 0.3125rem; + border-radius: 0.1875rem; + background: color-mix(in oklab, var(--primary) 72%, transparent); +} + +.activity-agenda-swipe-dot { + position: absolute; + width: 9%; + height: 0.375rem; + border-radius: 0.1875rem; + background: color-mix(in oklab, var(--primary) 72%, transparent); +} + +.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 +392,7 @@ } @media (prefers-reduced-motion: reduce) { + .activity-agenda, .activity-agenda *, .activity-agenda *::before, .activity-agenda *::after { diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index 98c7be5a..70842eb3 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -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(null) const [isNarrow, setIsNarrow] = useState(false) const [createDraft, setCreateDraft] = useState(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([]) + const swipePreviewKeyRef = useRef(null) + const adjacentEventsCacheRef = useRef(new Map()) // 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(() => { + 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,63 @@ 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) + setSwipePreviewEvents([]) + }, []) + + 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 +595,15 @@ export const ActivityAgenda = ({ noEventsText="Nenhum compromisso nesta janela" headingLevel={2} /> + + {swipePreview && visibleRange && adjacentPreviewRange ? ( + + ) : null} { + 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_END_MINUTES - SLOT_START_MINUTES, + ) + return (clamped / (SLOT_END_MINUTES - SLOT_START_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 ( + + ) +} diff --git a/src/components/campaign/activity/useAgendaSwipeNavigation.ts b/src/components/campaign/activity/useAgendaSwipeNavigation.ts index d5b51c83..a89afe8f 100644 --- a/src/components/campaign/activity/useAgendaSwipeNavigation.ts +++ b/src/components/campaign/activity/useAgendaSwipeNavigation.ts @@ -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,6 +69,8 @@ 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' @@ -54,7 +79,10 @@ type AgendaSwipeGesture = { 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 +90,29 @@ export const useAgendaSwipeNavigation = ({ enabled, blockRef, onSwipe, + onSwipePreviewStart, + onSwipePreviewEnd, }: { containerRef: RefObject /** Render-time gate (viewport/state-driven): when false, no gesture starts. */ enabled: boolean /** Event-time gate: when true, an in-flight gesture is abandoned (FC drag owns the pointer). */ blockRef: MutableRefObject + /** 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 } => { const gestureRef = useRef(null) const enabledRef = useRef(enabled) const blockRefRef = useRef(blockRef) const onSwipeRef = useRef(onSwipe) + const onSwipePreviewStartRef = useRef(onSwipePreviewStart) + const onSwipePreviewEndRef = useRef(onSwipePreviewEnd) const suppressTimerRef = useRef | null>(null) + const exitTimerRef = useRef | null>(null) /** * The day view has a single column, so a horizontal drag never leaves the * slot it started on: FullCalendar's dateClick fires (initialHit === @@ -87,6 +125,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 +137,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 | null) => { + const container = containerRef.current + if (!container) return + const clamped = direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0) + 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 +230,120 @@ 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 && + Math.abs(dx) > Math.abs(dy) * 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() + 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() + 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 (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) { + // Vertical drag (scrolling the timeline, rescheduling): never claim. + gestureRef.current = null + } + return + } + + if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) { + // 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 = gesture.direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0) + if ( + Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX && + Math.abs(dx) >= Math.abs(dy) * AGENDA_SWIPE_DOMINANCE + ) { + settleTransform() + onSwipePreviewEndRef.current?.() + onSwipeRef.current(gesture.direction) + return + } + endWithoutCommit() + } + }, + [endWithoutCommit, settleTransform], + ) useEffect(() => { const container = containerRef.current @@ -213,6 +372,7 @@ export const useAgendaSwipeNavigation = ({ useEffect( () => () => { if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current) + if (exitTimerRef.current) clearTimeout(exitTimerRef.current) }, [], ) diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 9b22f4c9..71f869eb 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -94,6 +94,112 @@ const ptBrMonthNames = [ 'Dezembro', ] as const +/** + * 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 shiftDays = direction === 'next' ? 1 : -1 + const shiftMonths = 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).toISOString() + return { + start: shiftedStart, + end: new Date(end.getTime() + days * 86_400_000).toISOString(), + anchorDate: formatBahiaCivilDate(new Date(start.getTime() + days * 86_400_000)), + } + } + + if (view === 'day' || view === 'week') { + return shiftRangeByDays(shiftDays * (view === 'day' ? 1 : 7)) + } + + const anchor = civilDateAnchor(range.anchorDate) + if (!anchor || anchor.day !== 1) return null + + const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shiftMonths) + const shiftedAnchor = `${shiftedAnchorParts.year}-${pad(shiftedAnchorParts.month)}-01` + if (view === 'list') { + const nextMonth = shiftCivilMonth(shiftedAnchorParts.year, shiftedAnchorParts.month, 1) + return { + start: civilInstantAtBahia(shiftedAnchor), + end: civilInstantAtBahia(`${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: civilInstantAtBahia(start), + end: civilInstantAtBahia(subtractBahiaCivilDays(start, -42)), + anchorDate: shiftedAnchor, + } +} + +/** + * Midnight of a Bahia civil date as a UTC instant. Bahia is fixed at UTC-3 + * (no DST since 2019), so the explicit `-03:00` offset is stable — the same + * convention FullCalendar's own range strings use. + */ +const civilInstantAtBahia = (civilDate: string): string => + new Date(`${civilDate}T00:00:00-03:00`).toISOString() + +const pad = (value: number): string => String(value).padStart(2, '0') + +/** Full civil-date anchor (`aaaa-mm-dd`) with day/month/year — the runtime + * passes `formatBahiaCivilDate(getDate())`, so the year is always present. */ +const civilDateAnchor = ( + civilDate: string, +): { year: number; month: number; day: 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 { year, month, day } +} + +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): { day: number; month: number } | null => { const [year, month, day] = civilDate.split('-').map(Number) if ( diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts index 7062b141..86e26630 100644 --- a/tests/e2e/campaignAgendaMobile.e2e.spec.ts +++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts @@ -62,6 +62,32 @@ const touchSwipe = async ( await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } +/** + * C110 — like `touchSwipe`, but pauses mid-gesture (after the 12px claim) for + * `inspect` to observe the live preview before the release decides. + */ +const touchDragWithInspection = async ( + cdp: CDPSession, + box: { x: number; y: number; width: number; height: number }, + fromRatio: number, + toRatio: number, + inspect: () => Promise, +) => { + 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 new Promise((resolve) => setTimeout(resolve, 40)) + if (i === 2) await inspect() + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + +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: [] }) @@ -347,3 +373,130 @@ 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 { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + const today = formatBahiaCivilDate(new Date()) + const tomorrow = civilDatePlusDays(today, 1) + + for (const [civilDate, hour, title] of [ + [today, '10:00', 'Compromisso mobile hoje'], + [tomorrow, '11:00', 'Compromisso mobile amanhã'], + ] as const) { + 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, + tags: ['Comício'], + status: 'confirmado', + startAt, + municipality: municipality.id, + }), + depth: 0, + }) + } + + await campaign.login(page, coordinator.email!, coordinator.password) + 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 touchDragWithInspection(cdp, agendaBox, 0.8, 0.3, 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 } = 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 touchDragWithInspection(cdp, agendaBox, 0.9, 0.78, async () => { + const preview = agendaSwipePreview(page) + await expect(preview).toBeVisible() + await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText( + dayLabelFor(civilDatePlusDays(today, 1)), + ) + }) + + // 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) + }) +}) diff --git a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts index 4a7bdaf1..97e8c127 100644 --- a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts +++ b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts @@ -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,233 @@ 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', + }) + 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', + }) + }) + + 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)', () => { + expect( + activityAgendaAdjacentPeriod( + 'list', + { + start: '2026-08-01T00:00:00-03:00', + end: '2026-09-01T00:00:00-03:00', + anchorDate: '2026-08-01', + }, + 'next', + ), + ).toEqual({ + start: '2026-09-01T03:00:00.000Z', + end: '2026-10-01T03:00:00.000Z', + anchorDate: '2026-09-01', + }) + }) + + it('retorna null quando o anchor do mês não é o 1º 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( + 'day', + { + start: 'não-é-data', + end: '2026-08-10T00:00:00-03:00', + anchorDate: '2026-08-09', + }, + 'next', + ), + ).toBeNull() + }) + + it('label do preview == título pós-commit (paridade dia/semana/mês/lista)', () => { + const day = activityAgendaAdjacentPeriod( + 'day', + { + start: '2026-08-09T00:00:00-03:00', + end: '2026-08-10T00:00:00-03:00', + anchorDate: '2026-08-09', + }, + 'next', + ) + expect(activityAgendaPeriodLabel('day', day!.start, day!.end, day!.anchorDate)).toBe( + '10 Agosto', + ) + + const week = activityAgendaAdjacentPeriod( + 'week', + { + start: '2026-07-27T00:00:00-03:00', + end: '2026-08-03T00:00:00-03:00', + anchorDate: '2026-07-27', + }, + 'next', + ) + expect(activityAgendaPeriodLabel('week', week!.start, week!.end, week!.anchorDate)).toBe( + '3–9 Agosto', + ) + + const month = activityAgendaAdjacentPeriod( + 'month', + { + start: '2026-07-26T00:00:00-03:00', + end: '2026-09-01T00:00:00-03:00', + anchorDate: '2026-08-01', + }, + 'next', + ) + expect(activityAgendaPeriodLabel('month', month!.start, month!.end, month!.anchorDate)).toBe( + 'Setembro', + ) + + const list = activityAgendaAdjacentPeriod( + 'list', + { + start: '2026-08-01T00:00:00-03:00', + end: '2026-09-01T00:00:00-03:00', + anchorDate: '2026-08-01', + }, + 'next', + ) + expect(activityAgendaPeriodLabel('list', list!.start, list!.end, list!.anchorDate)).toBe( + 'Agenda', + ) + }) +}) + describe('activityAgendaViewFromFcId', () => { it('espelha activityAgendaViewFcId para as quatro vistas', () => { expect(activityAgendaViewFromFcId('timeGridWeek')).toBe('week') diff --git a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx index ca484b92..8e7b5f87 100644 --- a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx +++ b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx @@ -27,11 +27,15 @@ const Harness = ({ enabled = true, blockRef, onSwipe, + onSwipePreviewStart, + onSwipePreviewEnd, onReady, }: { enabled?: boolean blockRef?: MutableRefObject onSwipe: (direction: 'next' | 'prev') => void + onSwipePreviewStart?: (direction: 'next' | 'prev') => void + onSwipePreviewEnd?: () => void onReady?: (suppressDateClickRef: MutableRefObject) => void }) => { const containerRef = useRef(null) @@ -41,16 +45,42 @@ 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 +89,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 +215,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 +243,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 | undefined const { container } = render( @@ -134,20 +291,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 | 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 +305,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 +316,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') }) }) From 3b81431d390fe94e916c77d62dc1acf49bb33cce Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 14:42:54 -0300 Subject: [PATCH 2/2] =?UTF-8?q?refactor(C110):=20simplify=20=E2=80=94=20he?= =?UTF-8?q?lpers=20de=20domin=C3=A2ncia/clamp,=20seed=20e2e=20generalizado?= =?UTF-8?q?,=20CSS=20sem=20regras=20mortas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...110-agenda-mobile-feedback-arrasto-impl.md | 6 + .../campaign/activity/ActivityAgenda.css | 22 +-- .../campaign/activity/ActivityAgenda.tsx | 3 +- .../activity/ActivityAgendaSwipePreview.tsx | 11 +- .../activity/useAgendaSwipeNavigation.ts | 38 ++-- src/utilities/activityUi.ts | 55 ++---- tests/e2e/campaignAgendaMobile.e2e.spec.ts | 185 +++++++++--------- .../activityAgendaPeriodLabel.unit.spec.ts | 109 ++++------- .../useAgendaSwipeNavigation.unit.spec.tsx | 3 +- 9 files changed, 190 insertions(+), 242 deletions(-) diff --git a/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md index 3ba5f72d..85263b0a 100644 --- a/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md +++ b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md @@ -77,3 +77,9 @@ flowchart LR - [ ] 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`. diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css index 30854dd4..95b40809 100644 --- a/src/components/campaign/activity/ActivityAgenda.css +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -95,11 +95,6 @@ padding: 0.625rem; } -.activity-agenda-swipe-preview--next .activity-agenda-swipe-preview-content { - align-items: flex-start; - text-align: left; -} - .activity-agenda-swipe-preview--prev .activity-agenda-swipe-preview-content { align-items: flex-end; text-align: right; @@ -135,8 +130,6 @@ var(--border) calc(14.285% - 1px), var(--border) 14.285% ); - background-size: 100% 100%; - position: relative; } .activity-agenda-swipe-frame[data-view='day'] { @@ -189,21 +182,22 @@ 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-event, +.activity-agenda-swipe-dot { position: absolute; - left: 0; - width: 62%; - height: 0.3125rem; 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 { - position: absolute; width: 9%; height: 0.375rem; - border-radius: 0.1875rem; - background: color-mix(in oklab, var(--primary) 72%, transparent); } .activity-agenda-swipe-list-row { diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index 70842eb3..268c9b96 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -403,7 +403,6 @@ export const ActivityAgenda = ({ const handleSwipePreviewEnd = useCallback(() => { swipePreviewKeyRef.current = null setSwipePreview(null) - setSwipePreviewEvents([]) }, []) useEffect(() => { @@ -596,6 +595,8 @@ export const ActivityAgenda = ({ headingLevel={2} /> + {/* `visibleRange &&` narrows the type for the preview's `view` prop + (the memo already guarantees it is non-null). */} {swipePreview && visibleRange && adjacentPreviewRange ? ( { 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_END_MINUTES - SLOT_START_MINUTES, - ) - return (clamped / (SLOT_END_MINUTES - SLOT_START_MINUTES)) * 100 + const clamped = Math.min(Math.max(minutes - SLOT_START_MINUTES, 0), SLOT_SPAN_MINUTES) + return (clamped / SLOT_SPAN_MINUTES) * 100 } const monthCellPosition = ( @@ -86,7 +84,8 @@ export const ActivityAgendaSwipePreview = ({ {/* 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). */} + accepted degradation of the intention). The day view keeps just + the time bands — a single-day header would mislabel the columns. */} {view !== 'list' ? (
{view === 'week' ? ( diff --git a/src/components/campaign/activity/useAgendaSwipeNavigation.ts b/src/components/campaign/activity/useAgendaSwipeNavigation.ts index a89afe8f..12a8b0b2 100644 --- a/src/components/campaign/activity/useAgendaSwipeNavigation.ts +++ b/src/components/campaign/activity/useAgendaSwipeNavigation.ts @@ -74,6 +74,17 @@ 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 @@ -150,10 +161,10 @@ export const useAgendaSwipeNavigation = ({ /** 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 | null) => { + (dx: number, direction: AgendaSwipeDirection) => { const container = containerRef.current if (!container) return - const clamped = direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0) + const clamped = clampDisplacement(dx, direction) container.style.transform = clamped === 0 ? '' : `translateX(${clamped}px)` }, [containerRef], @@ -247,10 +258,7 @@ export const useAgendaSwipeNavigation = ({ const dx = touch.clientX - gesture.startX const dy = touch.clientY - gesture.startY if (gesture.direction === null) { - if ( - Math.abs(dx) > AGENDA_SWIPE_CLAIM_PX && - Math.abs(dx) > Math.abs(dy) * AGENDA_SWIPE_DOMINANCE - ) { + 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 @@ -268,6 +276,8 @@ export const useAgendaSwipeNavigation = ({ // 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], @@ -289,14 +299,14 @@ export const useAgendaSwipeNavigation = ({ gesture.lastDy = dy if (gesture.direction === null) { - if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) { + if (isVerticalDrag(dx, dy)) { // Vertical drag (scrolling the timeline, rescheduling): never claim. gestureRef.current = null } return } - if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) { + 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. @@ -329,20 +339,16 @@ export const useAgendaSwipeNavigation = ({ if (gesture.direction !== null) { const dx = gesture.lastDx const dy = gesture.lastDy - const displaced = gesture.direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0) - if ( - Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX && - Math.abs(dx) >= Math.abs(dy) * AGENDA_SWIPE_DOMINANCE - ) { - settleTransform() - onSwipePreviewEndRef.current?.() + 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, settleTransform], + [endWithoutCommit], ) useEffect(() => { diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 71f869eb..2b86ca34 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -111,35 +111,34 @@ export const activityAgendaAdjacentPeriod = ( range: { start: string; end: string; anchorDate: string }, direction: 'next' | 'prev', ): { start: string; end: string; anchorDate: string } | null => { - const shiftDays = direction === 'next' ? 1 : -1 - const shiftMonths = direction === 'next' ? 1 : -1 + 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).toISOString() + const shiftedStart = new Date(start.getTime() + days * 86_400_000) return { - start: shiftedStart, + start: shiftedStart.toISOString(), end: new Date(end.getTime() + days * 86_400_000).toISOString(), - anchorDate: formatBahiaCivilDate(new Date(start.getTime() + days * 86_400_000)), + anchorDate: formatBahiaCivilDate(shiftedStart), } } if (view === 'day' || view === 'week') { - return shiftRangeByDays(shiftDays * (view === 'day' ? 1 : 7)) + return shiftRangeByDays(shift * (view === 'day' ? 1 : 7)) } - const anchor = civilDateAnchor(range.anchorDate) + const anchor = civilDateParts(range.anchorDate) if (!anchor || anchor.day !== 1) return null - const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shiftMonths) + 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: civilInstantAtBahia(shiftedAnchor), - end: civilInstantAtBahia(`${nextMonth.year}-${pad(nextMonth.month)}-01`), + start: allDayStartInstant(shiftedAnchor), + end: allDayStartInstant(`${nextMonth.year}-${pad(nextMonth.month)}-01`), anchorDate: shiftedAnchor, } } @@ -148,42 +147,14 @@ export const activityAgendaAdjacentPeriod = ( // and spans at most 6 weeks. const start = subtractBahiaCivilDays(shiftedAnchor, mondayOffsetOf(shiftedAnchor)) return { - start: civilInstantAtBahia(start), - end: civilInstantAtBahia(subtractBahiaCivilDays(start, -42)), + start: allDayStartInstant(start), + end: allDayStartInstant(subtractBahiaCivilDays(start, -42)), anchorDate: shiftedAnchor, } } -/** - * Midnight of a Bahia civil date as a UTC instant. Bahia is fixed at UTC-3 - * (no DST since 2019), so the explicit `-03:00` offset is stable — the same - * convention FullCalendar's own range strings use. - */ -const civilInstantAtBahia = (civilDate: string): string => - new Date(`${civilDate}T00:00:00-03:00`).toISOString() - const pad = (value: number): string => String(value).padStart(2, '0') -/** Full civil-date anchor (`aaaa-mm-dd`) with day/month/year — the runtime - * passes `formatBahiaCivilDate(getDate())`, so the year is always present. */ -const civilDateAnchor = ( - civilDate: string, -): { year: number; month: number; day: 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 { year, month, day } -} - const shiftCivilMonth = ( year: number, month: number, @@ -200,7 +171,7 @@ const mondayOffsetOf = (civilDate: string): number => { return (weekday + 6) % 7 } -const civilDateParts = (civilDate: string): { day: number; month: number } | null => { +const civilDateParts = (civilDate: string): { year: number; month: number; day: number } | null => { const [year, month, day] = civilDate.split('-').map(Number) if ( !Number.isInteger(year) || @@ -213,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 => { diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts index 86e26630..6846c894 100644 --- a/tests/e2e/campaignAgendaMobile.e2e.spec.ts +++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts @@ -43,48 +43,39 @@ 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 dispatchTouchDrag = async ( + cdp: CDPSession, + box: { x: number; y: number; width: number; height: number }, + fromRatio: number, + toRatio: number, + { + moveDelayMs = 25, + inspectAfterMove, + }: { moveDelayMs?: number; inspectAfterMove?: () => Promise } = {}, +) => { + 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 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, -) => { - 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 new Promise((resolve) => setTimeout(resolve, 25)) - } - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) -} - -/** - * C110 — like `touchSwipe`, but pauses mid-gesture (after the 12px claim) for - * `inspect` to observe the live preview before the release decides. - */ -const touchDragWithInspection = async ( - cdp: CDPSession, - box: { x: number; y: number; width: number; height: number }, - fromRatio: number, - toRatio: number, - inspect: () => Promise, -) => { - 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 new Promise((resolve) => setTimeout(resolve, 40)) - if (i === 2) await inspect() - } - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) -} +) => dispatchTouchDrag(cdp, box, fromRatio, toRatio) const agendaSwipePreview = (page: Page) => page.locator('.activity-agenda-swipe-preview') @@ -93,26 +84,43 @@ const tapAt = async (cdp: CDPSession, x: number, y: number) => { await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) } -const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise => { +/** + * 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 => { 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 => { + const civilDate = formatBahiaCivilDate(new Date()) + await seedAgendaActivities(campaign, page, [[civilDate, '10:00', 'Compromisso mobile nativo']]) return civilDate } @@ -381,32 +389,12 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit campaign: CampaignE2EFixture, page: Page, ): Promise<{ today: string; tomorrow: string }> => { - const { fixtures } = campaign - const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.claimMunicipality() const today = formatBahiaCivilDate(new Date()) const tomorrow = civilDatePlusDays(today, 1) - - for (const [civilDate, hour, title] of [ + await seedAgendaActivities(campaign, page, [ [today, '10:00', 'Compromisso mobile hoje'], [tomorrow, '11:00', 'Compromisso mobile amanhã'], - ] as const) { - 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, - tags: ['Comício'], - status: 'confirmado', - startAt, - municipality: municipality.id, - }), - depth: 0, - }) - } - - await campaign.login(page, coordinator.email!, coordinator.password) + ]) return { today, tomorrow } } @@ -429,22 +417,26 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit // 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 touchDragWithInspection(cdp, agendaBox, 0.8, 0.3, 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 }, + 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), ) - .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') + 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. @@ -461,7 +453,7 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit campaign, page, }) => { - const { today } = await seedTodayAndTomorrowActivities(campaign, page) + const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page) await page.setViewportSize({ width: 390, height: 844 }) await page.goto(`${campaign.baseURL}/campanha/agenda`) @@ -475,12 +467,15 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit // 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 touchDragWithInspection(cdp, agendaBox, 0.9, 0.78, async () => { - const preview = agendaSwipePreview(page) - await expect(preview).toBeVisible() - await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText( - dayLabelFor(civilDatePlusDays(today, 1)), - ) + 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. diff --git a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts index 97e8c127..6f780b6e 100644 --- a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts +++ b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts @@ -132,6 +132,15 @@ describe('activityAgendaAdjacentPeriod', () => { 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', @@ -165,6 +174,15 @@ describe('activityAgendaAdjacentPeriod', () => { 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)', () => { @@ -245,24 +263,26 @@ describe('activityAgendaAdjacentPeriod', () => { }) it('desloca a lista em um mês inteiro (range do mês adjacente)', () => { - expect( - activityAgendaAdjacentPeriod( - 'list', - { - start: '2026-08-01T00:00:00-03:00', - end: '2026-09-01T00:00:00-03:00', - anchorDate: '2026-08-01', - }, - 'next', - ), - ).toEqual({ + 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º ou o intervalo é ilegível', () => { + it('retorna null quando o anchor do mês não é o 1º, é malformado, ou o intervalo é ilegível', () => { expect( activityAgendaAdjacentPeriod( 'month', @@ -274,6 +294,17 @@ describe('activityAgendaAdjacentPeriod', () => { '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', @@ -286,60 +317,6 @@ describe('activityAgendaAdjacentPeriod', () => { ), ).toBeNull() }) - - it('label do preview == título pós-commit (paridade dia/semana/mês/lista)', () => { - const day = activityAgendaAdjacentPeriod( - 'day', - { - start: '2026-08-09T00:00:00-03:00', - end: '2026-08-10T00:00:00-03:00', - anchorDate: '2026-08-09', - }, - 'next', - ) - expect(activityAgendaPeriodLabel('day', day!.start, day!.end, day!.anchorDate)).toBe( - '10 Agosto', - ) - - const week = activityAgendaAdjacentPeriod( - 'week', - { - start: '2026-07-27T00:00:00-03:00', - end: '2026-08-03T00:00:00-03:00', - anchorDate: '2026-07-27', - }, - 'next', - ) - expect(activityAgendaPeriodLabel('week', week!.start, week!.end, week!.anchorDate)).toBe( - '3–9 Agosto', - ) - - const month = activityAgendaAdjacentPeriod( - 'month', - { - start: '2026-07-26T00:00:00-03:00', - end: '2026-09-01T00:00:00-03:00', - anchorDate: '2026-08-01', - }, - 'next', - ) - expect(activityAgendaPeriodLabel('month', month!.start, month!.end, month!.anchorDate)).toBe( - 'Setembro', - ) - - const list = activityAgendaAdjacentPeriod( - 'list', - { - start: '2026-08-01T00:00:00-03:00', - end: '2026-09-01T00:00:00-03:00', - anchorDate: '2026-08-01', - }, - 'next', - ) - expect(activityAgendaPeriodLabel('list', list!.start, list!.end, list!.anchorDate)).toBe( - 'Agenda', - ) - }) }) describe('activityAgendaViewFromFcId', () => { diff --git a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx index 8e7b5f87..566702b4 100644 --- a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx +++ b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx @@ -51,8 +51,7 @@ const Harness = ({ if (onReady) onReady(suppressDateClickRef) return createElement('div', { ref: containerRef, 'data-testid': 'agenda' }) } -const agendaOf = (container: HTMLElement): HTMLElement => - container.firstElementChild as HTMLElement +const agendaOf = (container: HTMLElement): HTMLElement => container.firstElementChild as HTMLElement /** Dispatches a cancellable touchmove and returns the event for assertion. */ const touchMoveAt = (agenda: HTMLElement, x: number, y: number): Event => {