mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #516 from fsolla/C101-agenda-mobile-calendario-com-cara-de-app-nativo-context
C101 — Agenda mobile: calendário com cara de app nativo (contexto no header, navegação por arrasto, filtro sticky, dia na hora atual)
This commit is contained in:
commit
a7dce2c716
17 changed files with 1437 additions and 92 deletions
84
docs/plans/c101-agenda-mobile-calendario-nativo-impl.md
Normal file
84
docs/plans/c101-agenda-mobile-calendario-nativo-impl.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Impl: C101 — Agenda mobile: calendário com cara de app nativo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #497
|
||||
Intenção: docs/plans/c101-agenda-mobile-calendario-nativo.md
|
||||
Appetite restante: ~1,5–2 dias eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no celular, `/campanha/agenda` se comporta como calendário nativo: contexto do período no header do app (no lugar de "Agenda"), navegação por arrasto horizontal, filtro edge-to-edge sem rótulo e sempre na tela, visão de dia abrindo na hora atual (fixa em 08:00) com o cabeçalho "domingo 9" fixo ao rolar.
|
||||
- **O que NÃO negociar:** desktop intacto (título + "< > Hoje" do FullCalendar permanecem); criação inline por tap (C91) e remanejo por arrasto (C15) intactos; seletor de vista C95 no header; líder lockdown; sem segunda barra de controles; sem mudar o filtro das outras listas.
|
||||
- **O que reavaliar:** a hipótese da intenção de que o cabeçalho do dia e o scroll da linha do tempo vêm do `scrollTime` existente — **verificado no browser: hoje não há scroll interno** (height auto → a página rola, o col-header "domingo 9" rola junto, `scrollTime` é inerte). O aceite exige scroll interno + cabeçalho fixo → precisa de altura fixa no mobile.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[ActivityAgenda (mobile)] --> B[datesSet → visibleRange+view]
|
||||
B --> C[activityAgendaPeriodLabel pure]
|
||||
C --> D[AgendaPeriodChrome → SetCampaignPageChrome]
|
||||
D --> H[TopBar: título período + tap→today]
|
||||
A --> G[useAgendaSwipeNavigation]
|
||||
G --> F[api.next/prev + touchcancel sintético]
|
||||
A --> I[headerToolbar=false + height liquid + scrollTimeReset=false]
|
||||
I --> J[FC: scroll interno, col-header fixo, 08:00]
|
||||
K[strip: .activity-agenda-filter-strip] --> L[edge-to-edge, sem label, sticky]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C | D
|
||||
**Recomendação:** A — combina os mecanismos que o código já possui (chrome override por página, ResizeObserver/breakpoints, FC height/scrollTime) sem collection, sem lib nova, sem tocar nas outras listas.
|
||||
**Rejeitadas:** B — capar o corpo do timegrid por CSS (max-height + overflow) dessincroniza o eixo de horas do corpo (FC só sincroniza scrollers quando `verticalScrolling`); C — `contentHeight` fixo em px ignora o tamanho real do viewport (quebra em telas menores); D — deixar como está (rolagem de página) falha o aceite "cabeçalho do dia fixo".
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`activityAgendaPeriodLabel`** (`src/utilities/activityUi.ts`): pura — `(view, startStr, endStr) => string | null`; usa `formatBahiaCivilDate` + nomes de mês pt-BR em array estático (sem Intl). `day → "9 Agosto"`, `week → "3–9 Agosto" | "28 Julho – 3 Agosto"` (end−1), `month → "Agosto"`, `list → "Agenda"`, inválido → `null`. Inversa `activityAgendaViewFromFcId` (espelha `activityAgendaViewFcId`).
|
||||
- **`ActivityAgenda.tsx`** (`src/components/campaign/activity/ActivityAgenda.tsx`): estado `visibleRange` ganha `view` (de `DatesSetInfo.view.type`); condicionais por `useIsMobileMeasured()` (janela <768 — o mesmo breakpoint da top bar `md:hidden`; **não** o container 640, para não vazar mobile-ismo no desktop 768–800 com sidebar):
|
||||
- `headerToolbar={isMobile ? false : { start: 'prev,next today', center: 'title' }}`
|
||||
- `height={isMobile ? '100%' : 'auto'}` + CSS `.activity-agenda { height: calc(100svh - …) }` no media ≤767px (tune no browser) → FC liga `verticalScrolling` → scroll interno + col-header fora do scroller (fixo por construção) + `scrollTime` deixa de ser inerte.
|
||||
- `scrollTimeReset={!isMobile}` → swipe dia→dia **preserva** o scroll do usuário; desktop mantém o reset atual.
|
||||
- `eventDragStart/eventDragStop` → ref `isDraggingEvent` (guarda do gesto).
|
||||
- label memo + `AgendaPeriodChrome` + `onToday` (useCallback: `api.today()` + `api.scrollToTime('08:00:00')`).
|
||||
- **`useAgendaSwipeNavigation`** (`src/components/campaign/activity/useAgendaSwipeNavigation.ts`): hook novo, agenda-only. Pointer events no container, `pointerType touch|pen`, limiar 48 px com dominância horizontal |dx| > 1.4·|dy|, um disparo por gesto. No consumo: `onSwipe('next'|'prev')` + `TouchEvent('touchcancel')` sintético no alvo do touchstart **antes** do `touchend` real — mata o timer de long-press (650 ms) do FC (senão o drag de evento começaria no meio do swipe) e o tap pendente. Enquanto `isDraggingEvent` → ignora (arrasto de remanejo é do FC).
|
||||
- **`AgendaPeriodChrome`** (`src/components/campaign/activity/AgendaPeriodChrome.tsx`): ponte agenda→header (padrão do `AgendaViewChrome`); `chrome = useMemo({ title: label, onTitleClick }, [label, onTitleClick])` + `useLayoutEffect` set/cleanup (o mesmo padrão do `SetCampaignPageChrome` — o flash "Agenda" entre commits não pinta, layout effects do mesmo commit são batched). Nada renderizado em desktop (só mobile seta).
|
||||
- **`CampaignPageChrome`** (`src/lib/campaignPageChrome.ts`): campo opcional `onTitleClick?: () => void`.
|
||||
- **`CampaignPageChromeText.tsx`** (branch mobile): quando `onTitleClick`, o título vira `<button>` (mantém truncate/flex; `title="Voltar para hoje"`; texto continua sendo o accessible name).
|
||||
- **`ActivityAgendaFilters.tsx`**: envolve a omnibox em `<div className="activity-agenda-filter-strip">`.
|
||||
- **`ActivityAgenda.css`**: bloco `@media (max-width: 767px)`:
|
||||
- strip: `margin-inline: -1rem; margin-top: -1rem; margin-bottom: -1.5rem` (edge-to-edge, colada na top bar e no topo do calendário), `position: sticky; top: 0; z-index: 20; background: var(--background)` (branco sólido do tema campaign); label virado `sr-only` (mantém accessible name do combobox).
|
||||
- `.activity-agenda { height: … }` para o `height='100%'` liquid (tune no browser; alvo ≈ `calc(100svh - 11rem)`).
|
||||
- **Migration:** nenhuma (só UI/estado de tela). Nenhum `Consent`/access novo.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma escolhida: rótulo de período em texto no header do app (título da página) — "o que estou olhando" num olhar, sem UI nova. Rejeitadas: chip/segunda linha no header (vira segunda barra — anti-goal); título custom dentro do calendário (duplicaria o contexto).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer (schema+server é nulo aqui) → mecânica do calendário**: label pura + unit tests; props condicionais (toolbar/height/scrollTimeReset) + verificação no browser (scroll interno, col-header fixo, 08:00, `scrollTimeReset=false` preservando scroll). Ajuste do `height` calc no browser.
|
||||
2. **UI**: `AgendaPeriodChrome` + `onTitleClick` no chrome compartilhado; strip CSS edge-to-edge/sticky; gesto de swipe + `touchcancel` sintético. Impeccable C: shape → craft → critique → polish localizado na superfície mobile da agenda.
|
||||
3. **Gates**: `pnpm gate:fast` na iteração; `pnpm push` na entrega. E2E: atualizar asserções mobile do C95 (toolbar some, título mostra o período) + novos testes C101 (swipe por CDP touch, tap no título volta ao hoje, strip sticky/sem label, dia abre em 08:00 com cabeçalho fixo).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Animação do swipe / transição suave entre períodos (FC7 não tem; corte: troca instantânea — prefer motion-reduce).
|
||||
- Ano no rótulo (mês "Agosto" cruzando ano fica ambíguo — corte: sem ano, como o aceite; registrar débito leve).
|
||||
- `touch-action` no container (proibido: `pan-y` quebraria o arrasto vertical de remanejo; o gesto horizontal não precisa — não há scroll horizontal na página).
|
||||
- Re-centrar dinamicamente na "hora atual" real (o aceite fixa 08:00; registrar como opção futura).
|
||||
- Gesto no desktop e arrasto com mouse; hamburger/sidebar (C102); mudar omnibox das outras listas.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Height liquid do FC7**: `height='100%'` + CSS no `.activity-agenda` — verificar no browser; fallback `contentHeight` em px só se o liquid não fechar (com tune por breakpoint).
|
||||
- **Conflito gesto × long-press do FC** (650 ms): swipe rápido em cima de evento — `touchcancel` sintético mata o timer do EventDragging; swipe lento (>650 ms) — `eventDragStart` já setou o guard → não navega. Janela residual coberta: o consumo exige 48 px com dominância; sem movimento, sem consumo.
|
||||
- **`TouchEvent` ausente em jsdom/unit**: guard `typeof TouchEvent !== 'undefined'` (browser tem; o unit test do hook cobre o disparo do `onSwipe`, não o cancel).
|
||||
- **Overflow horizontal**: o strip usa margens negativas idênticas ao `.activity-agenda-shell` (que já é edge-to-edge sem overflow) — o e2e existente (`scrollWidth <= innerWidth`) protege.
|
||||
- **Breakpoint 768–800 com sidebar**: `isMobile` é janela (<768) — a faixa desktop 768–800 com container estreito mantém o chrome desktop (toolbar visível, sem override, sem swipe). Comportamento existente do C95 (day view por container) intacto.
|
||||
- **E2E swipe via CDP**: `Input.dispatchTouchEvent` (precedente: WebAuthn usa CDP); swipe rápido em slot vazio (fixture cria 1 evento às 10:00 — alvo acima dele) para nunca cruzar a janela do long-press.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (toolbar some no mobile, título por período, swipe, strip sticky, 08:00, "domingo 9" fixo, desktop intacto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem prod, sem Consent novo, copy pt-BR / ids em inglês)
|
||||
- [ ] Testes de domínio: unit do label (day/week mês-cruzado/list/inválido) + hook do gesto (limiar/dominância/pointerType/guarda); e2e mobile novos + C95 ajustado; gate:fast e gate:push verdes
|
||||
46
docs/plans/c101-ux-pos-critique.md
Normal file
46
docs/plans/c101-ux-pos-critique.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Impl: Agenda mobile — affordance do "voltar ao hoje" + navegação por teclado (pós-C101)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #C101-ux
|
||||
Intenção: docs/plans/c101-agenda-mobile-calendario-nativo.md
|
||||
Appetite: ~0,5 dia eng (fill-in pós-entrega)
|
||||
|
||||
## Leitura do lote
|
||||
|
||||
- **Outcome:** no mobile da agenda, (1) usuários de teclado/AT conseguem navegar entre períodos mesmo com a toolbar do FullCalendar escondida (C101), e (2) o tap no título do header ("voltar ao hoje") tem uma pista visual — hoje só existe `title` (hover), invisível no toque.
|
||||
- **O que NÃO negociar:** desktop intacto (toolbar continua mandando lá); swipe continua sendo o gesto primário; C91 (tap cria inline) e C15 (arrasto de remanejo) intactos; nada de segunda barra de controles.
|
||||
- **O que reavaliar:** se o FullCalendar já entrega navegação por setas no grid focado (FC tem seleção por teclado interna) — talvez baste expor/focar o grid, sem handler próprio.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **a11y (primeiro):** teclado no mobile — ArrowLeft/ArrowRight navegam dia/semana/mês conforme a vista. Investigar o teclado interno do FC antes de criar handler próprio; se FC já navega com o grid focado, garantir que o foco chegue lá (o container tem `tabIndex`?). Teste unitário + e2e com `page.keyboard`.
|
||||
2. **UX:** pista visual do tap-no-título — opções: chip "Hoje" discreto ao lado do título no top bar (só na agenda mobile) | ícone de calendário | nada além do affordance de botão. Validar no gate (Impeccable B/C).
|
||||
3. **Gates:** `pnpm gate:fast` na iteração; `pnpm push` na entrega.
|
||||
|
||||
## Rabbit holes / Não escopo
|
||||
|
||||
- Redesenhar a top bar; mudar o swipe; segunda barra; desktop; outras listas.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- Handler próprio de teclado pode conflitar com o teclado interno do FC (seleção de célula) — verificar primeiro; se conflitar, restringir o handler a quando o foco está fora do grid.
|
||||
- Chip "Hoje" no top bar disputa espaço com [Semana ▾][sino] — medir no browser antes.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Contrato `onTitleClick: { action, hint }` no chrome compartilhado (copy da agenda não vaza para o componente genérico).
|
||||
- Helper compartilhado de rótulos de período (`tests/e2e/helpers/agendaPeriodLabels.ts`).
|
||||
- Sticky real do strip; suppress do dateClick no claim; guarda de resize; anchor civil do mês (incl. bug de parse UTC).
|
||||
- Robustez do e2e (poll, tolerâncias, scroller ancorado, stubMatchMedia).
|
||||
|
||||
## Explicitamente fora (descartes + defers com gatilho deste triage)
|
||||
|
||||
- **Defer:** validação do interplay pan-y × pointercancel em device real — gatilho: antes de qualquer evolução do gesto de swipe (o e2e CDP cobre o caminho Chromium, não o Safari/WebKit).
|
||||
- Descartados: variável CSS do budget de altura (P3); flash de hidratação do título (padrão B167 existente); nota de meia-noite nos e2e (documentada); side-effect do harness de teste (P3).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Teclado navega períodos no mobile sem conflitar com a seleção interna do FC
|
||||
- [ ] Tap no título comunica a ação sem depender de hover
|
||||
- [ ] Desktop, swipe, C91 e C15 intactos; gates verdes
|
||||
|
|
@ -37,6 +37,14 @@
|
|||
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
/* C101 — the horizontal swipe (navigation) must see pointermove events:
|
||||
without a touch-action the browser claims any touch drag for panning and
|
||||
fires pointercancel at the first move, killing the gesture before the
|
||||
threshold. pan-y keeps vertical scrolling native while horizontal
|
||||
movements stay with the page. FullCalendar's own drags survive: during an
|
||||
event drag it preventDefaults window touchmoves (its touchScrollAllowed
|
||||
cancellation), which stops the browser's pan claim. */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.activity-agenda button {
|
||||
|
|
@ -121,6 +129,47 @@
|
|||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* C101 — the agenda's filter strip. The wrapper class lives in
|
||||
ActivityAgendaFilters (agenda-only); the shared omnibox keeps its desktop
|
||||
look everywhere else. Mobile: edge-to-edge, label visually hidden (still
|
||||
the combobox's accessible name), sticky under the app top bar and flush
|
||||
against the calendar card.
|
||||
Sticky geometry: the wrapper's containing block must start flush under the
|
||||
app bar, otherwise the sticky clamp pins it 1rem lower. The app scroll
|
||||
container's top padding is therefore zeroed on the agenda page (scoped via
|
||||
`:has`), so the page shell — and with it the strip's containing block —
|
||||
starts at the top bar's bottom edge. */
|
||||
.activity-agenda-filter-strip {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-slot='campaign-content-scroll']:has(.activity-agenda-filter-strip) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.activity-agenda-filter-strip {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
margin-inline: -1rem;
|
||||
margin-bottom: -1.5rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.activity-agenda-filter-strip label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.activity-agenda-shell {
|
||||
/* Edge-to-edge on phones: break out of the p-4 scroll padding and drop
|
||||
|
|
@ -131,6 +180,11 @@
|
|||
}
|
||||
|
||||
.activity-agenda {
|
||||
/* C101 — the day/week/month grid becomes a self-contained scroller on
|
||||
phones (FullCalendar `height="100%"`), so the day header stays fixed
|
||||
while the hours scroll and `scrollTime`/`scrollToTime` can anchor the
|
||||
current hour. Budget: top bar + strip + bottom nav + page padding. */
|
||||
height: calc(100svh - 12rem);
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import themePlugin from '@fullcalendar/react/themes/classic'
|
|||
import '@fullcalendar/react/themes/classic/theme.css'
|
||||
import timeGridPlugin from '@fullcalendar/react/timegrid'
|
||||
import { UserRoundCheckIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
|
|
@ -29,17 +29,24 @@ import {
|
|||
ActivityInlineCreate,
|
||||
type ActivityInlineCreateDraft,
|
||||
} from '@/components/campaign/activity/ActivityInlineCreate'
|
||||
import { AgendaPeriodChrome } from '@/components/campaign/activity/AgendaPeriodChrome'
|
||||
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIsMobileMeasured } from '@/hooks/use-mobile'
|
||||
import { formatBahiaCivilDate } from '@/lib/campaignTime'
|
||||
import {
|
||||
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
|
||||
activityStatusLabels,
|
||||
type ActivityStatus,
|
||||
} from '@/lib/schemas/activity'
|
||||
import {
|
||||
activityAgendaPeriodLabel,
|
||||
activityAgendaViewFcId,
|
||||
activityAgendaViewFromFcId,
|
||||
activitySlotPrefill,
|
||||
type ActivityAgendaState,
|
||||
type ActivityAgendaView,
|
||||
} from '@/utilities/activityUi'
|
||||
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
|
||||
|
||||
|
|
@ -63,6 +70,15 @@ const eventColors: Record<ActivityStatus, { color: string; contrastColor: string
|
|||
|
||||
const MOBILE_BREAKPOINT_PX = 640
|
||||
|
||||
/**
|
||||
* C101 — the desktop chrome the FullCalendar toolbar keeps: title + prev/next
|
||||
* + today. Mobile replaces it with the app header (period title + swipe).
|
||||
*/
|
||||
const desktopToolbarConfig = { start: 'prev,next today', center: 'title' } as const
|
||||
|
||||
/** C101 — the fixed scroll anchor the day view opens at ("hoje é fixa em 08:00"). */
|
||||
const AGENDA_DAY_SCROLL_TIME = '08:00:00'
|
||||
|
||||
const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
|
||||
id: String(event.id),
|
||||
title: event.title,
|
||||
|
|
@ -116,7 +132,13 @@ export const ActivityAgenda = ({
|
|||
const narrowRef = useRef<boolean | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
const pendingEventIDs = useRef(new Set<string>())
|
||||
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null)
|
||||
const draggingEventRef = useRef(false)
|
||||
const [visibleRange, setVisibleRange] = useState<{
|
||||
start: string
|
||||
end: string
|
||||
anchorDate: string
|
||||
view: ActivityAgendaView
|
||||
} | null>(null)
|
||||
const [events, setEvents] = useState<EventInput[]>([])
|
||||
const [reloadCount, setReloadCount] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
|
@ -125,6 +147,13 @@ export const ActivityAgenda = ({
|
|||
const [isNarrow, setIsNarrow] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
|
||||
|
||||
// C101 — the mobile behaviors key on the WINDOW (<768, the app top bar's
|
||||
// own `md:hidden` breakpoint), not on the calendar container: a desktop
|
||||
// window with a narrow content pane (768–800 with the sidebar open) keeps
|
||||
// the desktop chrome. The container-based `isNarrow` keeps deciding the
|
||||
// responsive day/week fallback, as before C101.
|
||||
const { isMobile } = useIsMobileMeasured()
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
|
|
@ -210,14 +239,103 @@ export const ActivityAgenda = ({
|
|||
}
|
||||
}, [reloadCount, state, visibleRange])
|
||||
|
||||
const handleDatesSet = useCallback(({ startStr, endStr }: DatesSetInfo) => {
|
||||
const handleDatesSet = useCallback(({ startStr, endStr, view }: DatesSetInfo) => {
|
||||
const nextView = activityAgendaViewFromFcId(view.type)
|
||||
if (!nextView) return
|
||||
// The month grid's visible range starts on the leading Sunday of the
|
||||
// previous month — the period label anchors on the calendar's current
|
||||
// date (the 1st of the displayed month), which is what the title must
|
||||
// claim. Civil date (not the ISO instant): the anchor is only the
|
||||
// day/month of the label, and instants near midnight Bahia must not
|
||||
// churn the range identity.
|
||||
const anchorDate = formatBahiaCivilDate(view.calendar.getDate())
|
||||
setVisibleRange((current) =>
|
||||
current?.start === startStr && current.end === endStr
|
||||
current?.start === startStr &&
|
||||
current.end === endStr &&
|
||||
current.view === nextView &&
|
||||
current.anchorDate === anchorDate
|
||||
? current
|
||||
: { start: startStr, end: endStr },
|
||||
: { start: startStr, end: endStr, anchorDate, view: nextView },
|
||||
)
|
||||
}, [])
|
||||
|
||||
// C101 — the mobile header period context: derived from the range the
|
||||
// calendar actually shows (so it follows swipe navigation and view changes),
|
||||
// rendered only when the mobile top bar is the visible chrome.
|
||||
const periodLabel = useMemo(() => {
|
||||
if (!visibleRange) return null
|
||||
return activityAgendaPeriodLabel(
|
||||
visibleRange.view,
|
||||
visibleRange.start,
|
||||
visibleRange.end,
|
||||
visibleRange.anchorDate,
|
||||
)
|
||||
}, [visibleRange])
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
const api = calendarRef.current?.getApi()
|
||||
if (!api) return
|
||||
api.today()
|
||||
// C101 rabbit-hole cut: re-center on the way back to today (the day view
|
||||
// opens at 08:00), but never fight the user's scroll on day changes.
|
||||
api.scrollToTime(AGENDA_DAY_SCROLL_TIME)
|
||||
}, [])
|
||||
|
||||
const handleEventDragStart = useCallback(() => {
|
||||
draggingEventRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleEventDragStop = useCallback(() => {
|
||||
draggingEventRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleEventResizeStart = useCallback(() => {
|
||||
draggingEventRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleEventResizeStop = useCallback(() => {
|
||||
draggingEventRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleSwipeNavigation = useCallback((direction: 'next' | 'prev') => {
|
||||
const api = calendarRef.current?.getApi()
|
||||
if (!api) return
|
||||
if (direction === 'next') api.next()
|
||||
else api.prev()
|
||||
}, [])
|
||||
|
||||
const { suppressDateClickRef } = useAgendaSwipeNavigation({
|
||||
containerRef,
|
||||
enabled: isMobile,
|
||||
blockRef: draggingEventRef,
|
||||
onSwipe: handleSwipeNavigation,
|
||||
})
|
||||
|
||||
const handleDateClick = useCallback(
|
||||
(info: DateClickInfo) => {
|
||||
// C101 — a consumed swipe ends with FullCalendar's dateClick in the day
|
||||
// view (single column: the hit never leaves the starting slot). The
|
||||
// navigation already happened — swallow the click so it does not open
|
||||
// the inline create.
|
||||
if (suppressDateClickRef.current) return
|
||||
|
||||
const prefill = activitySlotPrefill(info)
|
||||
if (!prefill) return
|
||||
|
||||
const jsEvent = info.jsEvent as MouseEvent | undefined
|
||||
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
|
||||
const anchor = hasPoint
|
||||
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
|
||||
: (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
|
||||
})()
|
||||
|
||||
setCreateDraft({ ...prefill, anchor })
|
||||
},
|
||||
[suppressDateClickRef],
|
||||
)
|
||||
|
||||
const persistSchedule = useCallback(
|
||||
async ({
|
||||
id,
|
||||
|
|
@ -277,22 +395,6 @@ export const ActivityAgenda = ({
|
|||
[persistSchedule],
|
||||
)
|
||||
|
||||
const handleDateClick = useCallback((info: DateClickInfo) => {
|
||||
const prefill = activitySlotPrefill(info)
|
||||
if (!prefill) return
|
||||
|
||||
const jsEvent = info.jsEvent as MouseEvent | undefined
|
||||
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
|
||||
const anchor = hasPoint
|
||||
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
|
||||
: (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
|
||||
})()
|
||||
|
||||
setCreateDraft({ ...prefill, anchor })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="activity-agenda-shell" aria-label="Calendário de atividades">
|
||||
<div className="sr-only" aria-live="polite">
|
||||
|
|
@ -320,6 +422,8 @@ export const ActivityAgenda = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<AgendaPeriodChrome label={isMobile ? periodLabel : null} onToday={handleToday} />
|
||||
|
||||
<div className="activity-agenda" aria-busy={isLoading || savingCount > 0} ref={containerRef}>
|
||||
<FullCalendar
|
||||
ref={calendarRef}
|
||||
|
|
@ -328,14 +432,12 @@ export const ActivityAgenda = ({
|
|||
timeZone="America/Bahia"
|
||||
firstDay={1}
|
||||
initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}
|
||||
headerToolbar={{
|
||||
start: 'prev,next today',
|
||||
center: 'title',
|
||||
}}
|
||||
height="auto"
|
||||
headerToolbar={isMobile ? false : desktopToolbarConfig}
|
||||
height={isMobile ? '100%' : 'auto'}
|
||||
scrollTimeReset={!isMobile}
|
||||
slotMinTime="07:00:00"
|
||||
slotMaxTime="22:00:00"
|
||||
scrollTime="08:00:00"
|
||||
scrollTime={AGENDA_DAY_SCROLL_TIME}
|
||||
nowIndicator
|
||||
allDaySlot={false}
|
||||
dayMaxEvents
|
||||
|
|
@ -347,6 +449,10 @@ export const ActivityAgenda = ({
|
|||
dateClick={handleDateClick}
|
||||
eventDrop={handleScheduleChange}
|
||||
eventResize={handleScheduleChange}
|
||||
eventDragStart={handleEventDragStart}
|
||||
eventDragStop={handleEventDragStop}
|
||||
eventResizeStart={handleEventResizeStart}
|
||||
eventResizeStop={handleEventResizeStop}
|
||||
noEventsText="Nenhum compromisso nesta janela"
|
||||
headingLevel={2}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -105,51 +105,53 @@ export const ActivityAgendaFilters = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<CampaignListOmnibox
|
||||
id="agenda-omnibox"
|
||||
label="Filtrar agenda"
|
||||
placeholder="Filtrar por município, tag, deputado presente…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearActivityAgendaOmnibox())
|
||||
}}
|
||||
trailing={
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Planejar giro"
|
||||
title="Planejar giro"
|
||||
>
|
||||
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Nova atividade"
|
||||
title="Nova atividade"
|
||||
>
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="activity-agenda-filter-strip">
|
||||
<CampaignListOmnibox
|
||||
id="agenda-omnibox"
|
||||
label="Filtrar agenda"
|
||||
placeholder="Filtrar por município, tag, deputado presente…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearActivityAgendaOmnibox())
|
||||
}}
|
||||
trailing={
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Planejar giro"
|
||||
title="Planejar giro"
|
||||
>
|
||||
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Nova atividade"
|
||||
title="Nova atividade"
|
||||
>
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
31
src/components/campaign/activity/AgendaPeriodChrome.tsx
Normal file
31
src/components/campaign/activity/AgendaPeriodChrome.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
|
||||
/**
|
||||
* C101 — agenda page → app header bridge for the period context: while the
|
||||
* mobile calendar navigates, the top bar title shows the visible period
|
||||
* ("9 Agosto", "3–9 Agosto", "Agosto") instead of the catalog "Agenda", and
|
||||
* tapping it returns to today (the "Hoje" toolbar button is hidden on
|
||||
* phones). Desktop never mounts this — the agenda only renders it when the
|
||||
* mobile top bar is the visible chrome.
|
||||
*/
|
||||
export const AgendaPeriodChrome = ({
|
||||
label,
|
||||
onToday,
|
||||
}: {
|
||||
label: string | null
|
||||
onToday: () => void
|
||||
}) => {
|
||||
const chrome = useMemo(
|
||||
() =>
|
||||
label ? { title: label, onTitleClick: { action: onToday, hint: 'Voltar para hoje' } } : null,
|
||||
[label, onToday],
|
||||
)
|
||||
|
||||
if (!chrome) return null
|
||||
|
||||
return <SetCampaignPageChrome chrome={chrome} />
|
||||
}
|
||||
221
src/components/campaign/activity/useAgendaSwipeNavigation.ts
Normal file
221
src/components/campaign/activity/useAgendaSwipeNavigation.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* C101 — horizontal swipe navigation for the mobile agenda. FullCalendar has
|
||||
* no swipe gesture of its own, so this hook listens for a clear horizontal
|
||||
* drag on the calendar container and maps it to prev/next navigation.
|
||||
*
|
||||
* Chromium's pan controller claims any touch drag for scrolling the moment
|
||||
* the first touchmove passes its slop — firing pointercancel and ending the
|
||||
* pointer stream (on a real swipe the first move is small, so the 48px
|
||||
* threshold would never be reached). The fix is the classic swipe-vs-scroll
|
||||
* pattern: a NON-PASSIVE touchmove listener on the container calls
|
||||
* `preventDefault` as soon as the gesture is clearly horizontal (dead zone +
|
||||
* dominance), which stops the browser from claiming it. Vertical moves get no
|
||||
* preventDefault, so the timegrid keeps scrolling natively (and the browser
|
||||
* cancels the pointer stream — the gesture then resets, which is correct).
|
||||
*
|
||||
* Contract with FullCalendar's touch interactions (FC runs on touch events,
|
||||
* this hook on pointer events, both on the same gesture):
|
||||
*
|
||||
* - Tap (inline create, C91) needs no movement: a swipe crosses the
|
||||
* threshold and FC's own hit-equality (initialHit !== finalHit) already
|
||||
* suppresses the dateClick.
|
||||
* - The day view has a single column, so ANY horizontal drag ends with
|
||||
* initialHit === finalHit and FC fires a dateClick: the caller ignores
|
||||
* clicks while `suppressDateClickRef` is set. The flag arms at the 12px
|
||||
* claim (a sub-threshold flick must not open the inline create either)
|
||||
* and clears on the next pointerdown — a genuine tap after a failed swipe
|
||||
* keeps working.
|
||||
* - The event long-press (650ms, C15 reschedule) must not fire mid-swipe:
|
||||
* on consume we dispatch a synthetic `touchcancel` at the touchstart
|
||||
* target — browsers emit real touchcancel exactly when a gesture is taken
|
||||
* away, and FC treats it as a touch end, clearing its pending timers.
|
||||
* - A drag FC already owns (eventDragStart/eventResizeStart fired) is
|
||||
* blocked via `blockRef`: slow horizontal drags on events stay
|
||||
* reschedules.
|
||||
*
|
||||
* Only touch/pen pointers navigate — mouse keeps the desktop toolbar.
|
||||
*/
|
||||
|
||||
const AGENDA_SWIPE_THRESHOLD_PX = 48
|
||||
const AGENDA_SWIPE_DOMINANCE = 1.4
|
||||
/** Horizontal movement that already proves intent (pan claim dead zone). */
|
||||
const AGENDA_SWIPE_CLAIM_PX = 12
|
||||
/** Backstop for the dateClick suppression when no next gesture ever comes. */
|
||||
const AGENDA_CLICK_SUPPRESSION_MS = 500
|
||||
|
||||
type AgendaSwipeDirection = 'next' | 'prev'
|
||||
|
||||
type AgendaSwipeGesture = {
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
startTarget: EventTarget | null
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
export const useAgendaSwipeNavigation = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
blockRef,
|
||||
onSwipe,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
/** Render-time gate (viewport/state-driven): when false, no gesture starts. */
|
||||
enabled: boolean
|
||||
/** Event-time gate: when true, an in-flight gesture is abandoned (FC drag owns the pointer). */
|
||||
blockRef: MutableRefObject<boolean>
|
||||
onSwipe: (direction: AgendaSwipeDirection) => void
|
||||
}): { suppressDateClickRef: MutableRefObject<boolean> } => {
|
||||
const gestureRef = useRef<AgendaSwipeGesture | null>(null)
|
||||
const enabledRef = useRef(enabled)
|
||||
const blockRefRef = useRef(blockRef)
|
||||
const onSwipeRef = useRef(onSwipe)
|
||||
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/**
|
||||
* The day view has a single column, so a horizontal drag never leaves the
|
||||
* slot it started on: FullCalendar's dateClick fires (initialHit ===
|
||||
* finalHit) at the end of the gesture. The caller ignores that click —
|
||||
* the drag either navigated (consume) or was a sub-threshold flick, and
|
||||
* neither may open the inline create.
|
||||
*/
|
||||
const suppressDateClickRef = useRef(false)
|
||||
|
||||
enabledRef.current = enabled
|
||||
blockRefRef.current = blockRef
|
||||
onSwipeRef.current = onSwipe
|
||||
|
||||
const armClickSuppression = useCallback(() => {
|
||||
suppressDateClickRef.current = true
|
||||
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current)
|
||||
suppressTimerRef.current = setTimeout(() => {
|
||||
suppressTimerRef.current = null
|
||||
suppressDateClickRef.current = false
|
||||
}, AGENDA_CLICK_SUPPRESSION_MS)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback((event: PointerEvent) => {
|
||||
if (!enabledRef.current || blockRefRef.current.current) return
|
||||
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
|
||||
if (event.isPrimary === false) return
|
||||
|
||||
// A new gesture means the previous one ended: its dateClick (if any) has
|
||||
// already fired, so a genuine tap right after a failed swipe is safe.
|
||||
suppressDateClickRef.current = false
|
||||
if (suppressTimerRef.current) {
|
||||
clearTimeout(suppressTimerRef.current)
|
||||
suppressTimerRef.current = null
|
||||
}
|
||||
|
||||
gestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startTarget: event.target,
|
||||
consumed: false,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || gesture.consumed || blockRefRef.current.current) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
|
||||
const deltaX = touch.clientX - gesture.startX
|
||||
const deltaY = touch.clientY - gesture.startY
|
||||
if (
|
||||
Math.abs(deltaX) > AGENDA_SWIPE_CLAIM_PX &&
|
||||
Math.abs(deltaX) > Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE
|
||||
) {
|
||||
// Clearly horizontal: keep the browser from claiming the gesture for
|
||||
// panning (which would pointercancel the stream before the
|
||||
// threshold) AND suppress the dateClick a sub-threshold flick would
|
||||
// otherwise trigger in the day view.
|
||||
event.preventDefault()
|
||||
armClickSuppression()
|
||||
}
|
||||
},
|
||||
[armClickSuppression],
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || gesture.consumed || event.pointerId !== gesture.pointerId) return
|
||||
if (blockRefRef.current.current) {
|
||||
gestureRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - gesture.startX
|
||||
const deltaY = event.clientY - gesture.startY
|
||||
if (Math.abs(deltaX) < AGENDA_SWIPE_THRESHOLD_PX) return
|
||||
if (Math.abs(deltaX) < Math.abs(deltaY) * AGENDA_SWIPE_DOMINANCE) {
|
||||
// Vertical drag (scrolling the timeline, rescheduling): never navigate.
|
||||
gesture.consumed = true
|
||||
return
|
||||
}
|
||||
|
||||
gesture.consumed = true
|
||||
|
||||
// Kill FullCalendar's pending long-press/tap timers FIRST: its touch
|
||||
// listeners are attached to the touchstart target, so the cancel must be
|
||||
// dispatched there while that node is still connected (synthetic
|
||||
// touchcancel = the browser taking the gesture away).
|
||||
const startTarget = gesture.startTarget
|
||||
if (typeof TouchEvent !== 'undefined' && startTarget instanceof Element) {
|
||||
startTarget.dispatchEvent(
|
||||
new TouchEvent('touchcancel', { bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
armClickSuppression()
|
||||
onSwipeRef.current(deltaX < 0 ? 'next' : 'prev')
|
||||
},
|
||||
[armClickSuppression],
|
||||
)
|
||||
|
||||
const handlePointerEnd = useCallback((event: PointerEvent) => {
|
||||
const gesture = gestureRef.current
|
||||
if (!gesture || event.pointerId !== gesture.pointerId) return
|
||||
gestureRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
container.addEventListener('pointerdown', handlePointerDown)
|
||||
// Non-passive: the horizontal-claim preventDefault must reach the browser
|
||||
// before the pan controller decides (see module doc).
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
return () => {
|
||||
container.removeEventListener('pointerdown', handlePointerDown)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
}
|
||||
}, [containerRef, handlePointerDown, handleTouchMove])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerEnd)
|
||||
window.addEventListener('pointercancel', handlePointerEnd)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerEnd)
|
||||
window.removeEventListener('pointercancel', handlePointerEnd)
|
||||
}
|
||||
}, [handlePointerMove, handlePointerEnd])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { suppressDateClickRef }
|
||||
}
|
||||
|
|
@ -26,9 +26,24 @@ export const CampaignPageChromeText = ({
|
|||
|
||||
return (
|
||||
<div data-slot="campaign-page-chrome" className={cn('min-w-0 flex-1 leading-tight', className)}>
|
||||
<span data-slot="campaign-page-chrome-title" className="block truncate text-sm font-semibold">
|
||||
{chrome.title}
|
||||
</span>
|
||||
{chrome.onTitleClick ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="campaign-page-chrome-title"
|
||||
title={chrome.onTitleClick.hint}
|
||||
onClick={chrome.onTitleClick.action}
|
||||
className="block max-w-full truncate text-left text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{chrome.title}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
data-slot="campaign-page-chrome-title"
|
||||
className="block truncate text-sm font-semibold"
|
||||
>
|
||||
{chrome.title}
|
||||
</span>
|
||||
)}
|
||||
{chrome.subtitle ? (
|
||||
<span className="block truncate text-xs text-primary-foreground/80">{chrome.subtitle}</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ import type { CampaignUser } from '@/payload-types'
|
|||
export type CampaignPageChrome = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
/**
|
||||
* C101 — page-owned action on the app-header title (mobile layout only).
|
||||
* The agenda uses it to make the period label the "return to today"
|
||||
* control, since the FullCalendar "Hoje" toolbar button is hidden on
|
||||
* phones. Only client-rendered overrides set it — the catalog never does.
|
||||
* The `hint` is the page's own copy (rendered as the button tooltip), so
|
||||
* the shared chrome does not hardcode agenda wording.
|
||||
*/
|
||||
onTitleClick?: { action: () => void; hint: string }
|
||||
}
|
||||
|
||||
type CampaignRole = CampaignUser['role']
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
|
||||
import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
|
||||
import {
|
||||
formatBahiaCivilDate,
|
||||
parseBahiaDateTimeInput,
|
||||
subtractBahiaCivilDays,
|
||||
} from '@/lib/campaignTime'
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import {
|
||||
activityStatuses,
|
||||
|
|
@ -64,6 +68,100 @@ export const activityAgendaViewFcId: Record<ActivityAgendaView, string> = {
|
|||
list: 'listMonth',
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `activityAgendaViewFcId`: the FullCalendar view id reported by
|
||||
* `datesSet` back to the agenda's own view vocabulary.
|
||||
*/
|
||||
export const activityAgendaViewFromFcId = (fcId: string): ActivityAgendaView | null => {
|
||||
const match = activityAgendaViews.find((view) => activityAgendaViewFcId[view] === fcId)
|
||||
return match ?? null
|
||||
}
|
||||
|
||||
const ptBrMonthNames = [
|
||||
'Janeiro',
|
||||
'Fevereiro',
|
||||
'Março',
|
||||
'Abril',
|
||||
'Maio',
|
||||
'Junho',
|
||||
'Julho',
|
||||
'Agosto',
|
||||
'Setembro',
|
||||
'Outubro',
|
||||
'Novembro',
|
||||
'Dezembro',
|
||||
] as const
|
||||
|
||||
const civilDateParts = (civilDate: string): { day: number; month: number } | null => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { day, month }
|
||||
}
|
||||
|
||||
const civilDateLabel = (civilDate: string): string | null => {
|
||||
const parts = civilDateParts(civilDate)
|
||||
if (!parts) return null
|
||||
return `${parts.day} ${ptBrMonthNames[parts.month - 1]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* C101 — the period context the mobile app header shows while the calendar
|
||||
* navigates: day → "9 Agosto"; week → "3–9 Agosto" (visible Mon–Sun, months
|
||||
* spelled out when the range crosses one: "28 Julho – 3 Agosto"); month →
|
||||
* "Agosto"; list → "Agenda" (the dates live in the list body). The month
|
||||
* label uses the view's current date, NOT the grid range start: the month
|
||||
* grid's visible range begins on the leading Sunday of the previous month.
|
||||
* Returns null when the range cannot be read, so the header falls back to
|
||||
* the catalog title instead of inventing a label.
|
||||
*/
|
||||
export const activityAgendaPeriodLabel = (
|
||||
view: ActivityAgendaView,
|
||||
startStr: string,
|
||||
endStr: string,
|
||||
currentDateStr: string,
|
||||
): string | null => {
|
||||
if (view === 'list') return 'Agenda'
|
||||
|
||||
const civilDateOf = (value: string): string | null => {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : formatBahiaCivilDate(date)
|
||||
}
|
||||
|
||||
const start = civilDateOf(startStr)
|
||||
const end = civilDateOf(endStr)
|
||||
const startParts = start ? civilDateParts(start) : null
|
||||
const endParts = end ? civilDateParts(end) : null
|
||||
if (!start || !end || !startParts || !endParts) return null
|
||||
|
||||
if (view === 'day') return civilDateLabel(start)
|
||||
|
||||
// The anchor is ALREADY a Bahia civil date (`formatBahiaCivilDate` output,
|
||||
// no timezone) — parsing it as an instant would re-derive it from UTC
|
||||
// midnight and shift it back a day (2026-09-01 → 31 Agosto in Bahia).
|
||||
const currentParts = civilDateParts(currentDateStr)
|
||||
if (view === 'month') {
|
||||
if (!currentParts) return null
|
||||
return ptBrMonthNames[currentParts.month - 1]
|
||||
}
|
||||
|
||||
const weekEnd = civilDateParts(subtractBahiaCivilDays(end, 1))
|
||||
if (!weekEnd) return null
|
||||
if (weekEnd.month === startParts.month) {
|
||||
return `${startParts.day}–${weekEnd.day} ${ptBrMonthNames[startParts.month - 1]}`
|
||||
}
|
||||
return `${startParts.day} ${ptBrMonthNames[startParts.month - 1]} – ${weekEnd.day} ${ptBrMonthNames[weekEnd.month - 1]}`
|
||||
}
|
||||
|
||||
const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView =>
|
||||
activityAgendaViews.includes(value as ActivityAgendaView)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
|
||||
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
||||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import { dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js'
|
||||
|
||||
test.describe('Atividades — registro-fundação', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
|
@ -344,26 +345,38 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
.click()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
// C101 — the mobile calendar drops the FullCalendar toolbar: the period
|
||||
// context lives in the app header instead (the current month's name).
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
const todayCivil = formatBahiaCivilDate(new Date())
|
||||
const monthLabel = ptBrMonthNames[Number(todayCivil.slice(5, 7)) - 1] ?? ''
|
||||
await expect(campaignPageChrome(page, monthLabel)).toBeVisible()
|
||||
|
||||
// The mobile top bar selector still switches modes. The list view shares
|
||||
// the "Este mês" today label but drops the calendar grid.
|
||||
// The mobile top bar selector still switches modes. The list view drops
|
||||
// the calendar grid and the header goes back to the plain "Agenda" (the
|
||||
// dates live in the list body).
|
||||
await viewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
|
||||
await expect(page.getByRole('grid')).toHaveCount(0, { timeout: 15_000 })
|
||||
|
||||
// A navigation that drops the `view` param returns the calendar to the
|
||||
// responsive default instead of keeping an orphan view the selector no
|
||||
// longer claims (sidebar link / back-forward). Still on the 390px
|
||||
// viewport, so the narrow default is day ("Hoje" today label, grid back).
|
||||
// viewport, so the narrow default is day (grid back, no toolbar: the
|
||||
// header shows today's period instead of a "Hoje" button).
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
|
||||
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
const todayLabel = dayLabelFor(formatBahiaCivilDate(new Date()))
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
271
tests/e2e/campaignAgendaMobile.e2e.spec.ts
Normal file
271
tests/e2e/campaignAgendaMobile.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import type { CDPSession, Locator, Page } from '@playwright/test'
|
||||
|
||||
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
|
||||
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
||||
import {
|
||||
campaignPageChrome,
|
||||
expect,
|
||||
test,
|
||||
type CampaignE2EFixture,
|
||||
} from './fixtures/campaignE2EFixtures.js'
|
||||
import {
|
||||
civilDatePlusDays,
|
||||
dayLabelFor,
|
||||
ptBrMonthNames,
|
||||
weekdayOf,
|
||||
} from './helpers/agendaPeriodLabels.js'
|
||||
|
||||
const mobileHeaderTitle = (page: Page): Locator =>
|
||||
page.locator('[data-slot="campaign-mobile-top-bar"] [data-slot="campaign-page-chrome-title"]')
|
||||
|
||||
/**
|
||||
* Horizontal swipe via the CDP touch pipeline (the same input path a real
|
||||
* device takes — the only way to exercise touch gestures in Playwright).
|
||||
* The swipe stays at the vertical middle of the calendar, clear of fixture
|
||||
* events (which sit at 10:00), and completes far below FullCalendar's 650ms
|
||||
* long-press, so it is a navigation, not a reschedule.
|
||||
*/
|
||||
const touchSwipe = async (
|
||||
cdp: CDPSession,
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
fromRatio: number,
|
||||
toRatio: number,
|
||||
) => {
|
||||
const y = Math.round(box.y + box.height * 0.5)
|
||||
const x0 = Math.round(box.x + box.width * fromRatio)
|
||||
const x1 = Math.round(box.x + box.width * toRatio)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y }] })
|
||||
for (let i = 1; i <= 5; i += 1) {
|
||||
const x = Math.round(x0 + ((x1 - x0) * i) / 5)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] })
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
}
|
||||
|
||||
const tapAt = async (cdp: CDPSession, x: number, y: number) => {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] })
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
}
|
||||
|
||||
const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise<string> => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const civilDate = formatBahiaCivilDate(new Date())
|
||||
const startAt = parseBahiaDateTimeInput(`${civilDate}T10:00`)
|
||||
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
|
||||
|
||||
await fixtures.payload.create({
|
||||
collection: 'activity',
|
||||
data: hookFilledCreateData<'activity'>({
|
||||
title: fixtures.value('Compromisso mobile nativo'),
|
||||
tags: ['Comício'],
|
||||
status: 'confirmado',
|
||||
startAt,
|
||||
municipality: municipality.id,
|
||||
}),
|
||||
depth: 0,
|
||||
})
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
return civilDate
|
||||
}
|
||||
|
||||
test.describe('C101 — agenda mobile com cara de app nativo', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
||||
test('toolbar some, o título mostra o período e tap volta ao hoje', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const civilDate = await seedTodayActivity(campaign, page)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
|
||||
// C101 — o título do FullCalendar e os botões "< > Hoje" somem no mobile:
|
||||
// o header do app mostra o contexto do período no lugar.
|
||||
const todayLabel = dayLabelFor(civilDate)
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Dia Anterior' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Próximo Dia' })).toHaveCount(0)
|
||||
|
||||
// Navegação por arrasto: esquerda → dia seguinte; direita → volta. O
|
||||
// swipe direito começa longe da borda esquerda (x=0.4): um gesto que
|
||||
// nasça na borda é do Chrome (histórico), não da agenda.
|
||||
// `.first()`: FC mantém grids de vista antigos no DOM durante transições.
|
||||
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
|
||||
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
|
||||
const nextCivil = civilDatePlusDays(civilDate, 1)
|
||||
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
|
||||
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({ timeout: 15_000 })
|
||||
await touchSwipe(cdp, agendaBox, 0.4, 0.85)
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// O swipe não abre o inline create (dia tem uma coluna só: o hit do FC
|
||||
// nunca sai do slot inicial e o dateClick precisa ser engolido).
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
|
||||
// Tap no título do header volta ao hoje (o "Hoje" da toolbar não existe
|
||||
// mais no mobile). Sai primeiro do hoje para o tap ter efeito.
|
||||
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
|
||||
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
const titleBox = await mobileHeaderTitle(page).boundingBox()
|
||||
if (!titleBox) throw new Error('Título do header não expôs área de toque.')
|
||||
await tapAt(
|
||||
cdp,
|
||||
Math.round(titleBox.x + titleBox.width / 2),
|
||||
Math.round(titleBox.y + titleBox.height / 2),
|
||||
)
|
||||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Contexto por vista: mês → nome do mês; swipe no mês → mês seguinte;
|
||||
// lista → "Agenda".
|
||||
const viewSelector = page
|
||||
.getByRole('button', { name: /Modo de visualização/ })
|
||||
.filter({ visible: true })
|
||||
await viewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
|
||||
const monthIndex = Number(civilDate.slice(5, 7)) - 1
|
||||
await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
|
||||
await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await viewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// O strip edge-to-edge não pode criar overflow horizontal.
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('visão de dia abre na hora atual e o cabeçalho do dia fica fixo ao rolar', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const civilDate = await seedTodayActivity(campaign, page)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
const dayLabel = dayLabelFor(civilDate)
|
||||
await expect(campaignPageChrome(page, dayLabel)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// C101 — o grid de dia vira um scroller próprio (height fixa no mobile):
|
||||
// abre ancorado em 08:00 ("hoje é fixa em 08:00")...
|
||||
// `.first()`: FC mantém grids de vista antigos no DOM durante transições.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const scroller = [
|
||||
...document.querySelector('.activity-agenda')!.querySelectorAll('*'),
|
||||
].find((el) => {
|
||||
const style = getComputedStyle(el)
|
||||
return (
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
el.querySelector('[role="rowheader"]')
|
||||
)
|
||||
})
|
||||
return scroller ? scroller.scrollTop : null
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// ...e o cabeçalho "domingo 9" permanece no topo enquanto os horários
|
||||
// rolam.
|
||||
const colHeaderText = `${weekdayOf(civilDate)} ${Number(civilDate.slice(8, 10))}`
|
||||
const headerTop = () =>
|
||||
page.evaluate((text) => {
|
||||
const cell = [
|
||||
...document.querySelector('.activity-agenda')!.querySelectorAll('[role="columnheader"]'),
|
||||
].find((el) => el.textContent?.includes(text))
|
||||
return cell ? Math.round(cell.getBoundingClientRect().top) : null
|
||||
}, colHeaderText)
|
||||
|
||||
const headerBefore = await headerTop()
|
||||
expect(headerBefore).not.toBeNull()
|
||||
|
||||
const scrolledTo = await page.evaluate(() => {
|
||||
const scroller = [...document.querySelector('.activity-agenda')!.querySelectorAll('*')].find(
|
||||
(el) => {
|
||||
const style = getComputedStyle(el)
|
||||
return (
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
el.querySelector('[role="rowheader"]')
|
||||
)
|
||||
},
|
||||
)
|
||||
if (!scroller) return false
|
||||
scroller.scrollTop = 300
|
||||
return scroller.scrollTop
|
||||
})
|
||||
expect(scrolledTo).toBeGreaterThan(0)
|
||||
|
||||
expect(await headerTop()).toBe(headerBefore)
|
||||
})
|
||||
|
||||
test('o filtro vira strip edge-to-edge sem rótulo, colado na barra e no calendário', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const civilDate = await seedTodayActivity(campaign, page)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
// Espera o layout mobile assentar (B167): o painel de chat renderiza
|
||||
// "desktop com chat aberto" no primeiro frame e rouba largura do
|
||||
// conteúdo até a medição do viewport — o título do período no header só
|
||||
// existe no layout mobile de verdade.
|
||||
await expect(campaignPageChrome(page, dayLabelFor(civilDate))).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// O rótulo some visualmente (sr-only: 0 de altura renderizada) mas
|
||||
// continua sendo o nome acessível do campo.
|
||||
const labelHeight = await page.evaluate(() => {
|
||||
const label = document.querySelector('.activity-agenda-filter-strip label')
|
||||
return label ? Math.round(label.getBoundingClientRect().height) : null
|
||||
})
|
||||
expect(labelHeight).not.toBeNull()
|
||||
expect(labelHeight!).toBeLessThanOrEqual(1)
|
||||
await expect(page.getByRole('combobox', { name: 'Filtrar agenda' })).toBeVisible()
|
||||
|
||||
// Edge-to-edge, encostada na barra do app e no limite superior do
|
||||
// calendário, sticky enquanto navega. Tolerância de 1px para o
|
||||
// arredondamento de subpixel (precedente actionGridGeometry).
|
||||
const geometry = await page.evaluate(() => {
|
||||
const strip = document.querySelector('.activity-agenda-filter-strip')
|
||||
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
const calendar = document.querySelector('.activity-agenda-shell')
|
||||
if (!strip || !topBar || !calendar) return null
|
||||
const stripRect = strip.getBoundingClientRect()
|
||||
return {
|
||||
stripTop: Math.round(stripRect.top),
|
||||
topBarBottom: Math.round(topBar.getBoundingClientRect().bottom),
|
||||
stripBottom: Math.round(stripRect.bottom),
|
||||
calendarTop: Math.round(calendar.getBoundingClientRect().top),
|
||||
stripWidth: Math.round(stripRect.width),
|
||||
windowWidth: window.innerWidth,
|
||||
sticky: getComputedStyle(strip).position,
|
||||
}
|
||||
})
|
||||
expect(geometry).not.toBeNull()
|
||||
expect(Math.abs(geometry!.stripTop - geometry!.topBarBottom)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry!.stripBottom - geometry!.calendarTop)).toBeLessThanOrEqual(1)
|
||||
expect(geometry!.stripWidth).toBe(geometry!.windowWidth)
|
||||
expect(geometry!.sticky).toBe('sticky')
|
||||
})
|
||||
})
|
||||
|
|
@ -516,7 +516,7 @@ export const expectCampaignBiometricsReady = async (page: Page): Promise<void> =
|
|||
}).toPass({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
type CampaignE2EFixture = {
|
||||
export type CampaignE2EFixture = {
|
||||
baseURL: string
|
||||
fixtures: CampaignE2EOwnership
|
||||
login: (page: Page, identifier: string, password: string) => Promise<void>
|
||||
|
|
|
|||
48
tests/e2e/helpers/agendaPeriodLabels.ts
Normal file
48
tests/e2e/helpers/agendaPeriodLabels.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Agenda mobile (C101) — the header period label is computed from the
|
||||
* calendar's current date, so the e2e specs derive the expected label from
|
||||
* "today" instead of hardcoding it. Shared by the agenda mobile spec and the
|
||||
* C95 view-switching spec. Production vocabulary lives in
|
||||
* `src/utilities/activityUi.ts`; these helpers only mirror the label FORMAT
|
||||
* for assertions (the unit spec pins the literal strings).
|
||||
*/
|
||||
|
||||
export const ptBrMonthNames = [
|
||||
'Janeiro',
|
||||
'Fevereiro',
|
||||
'Março',
|
||||
'Abril',
|
||||
'Maio',
|
||||
'Junho',
|
||||
'Julho',
|
||||
'Agosto',
|
||||
'Setembro',
|
||||
'Outubro',
|
||||
'Novembro',
|
||||
'Dezembro',
|
||||
]
|
||||
|
||||
const ptBrWeekdays = [
|
||||
'domingo',
|
||||
'segunda-feira',
|
||||
'terça-feira',
|
||||
'quarta-feira',
|
||||
'quinta-feira',
|
||||
'sexta-feira',
|
||||
'sábado',
|
||||
]
|
||||
|
||||
/** "2026-08-09" → "9 Agosto" (the mobile header period label). */
|
||||
export const dayLabelFor = (civilDate: string): string =>
|
||||
`${Number(civilDate.slice(8, 10))} ${ptBrMonthNames[Number(civilDate.slice(5, 7)) - 1]}`
|
||||
|
||||
export const civilDatePlusDays = (civilDate: string, days: number): string => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year, month - 1, day + days))
|
||||
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const weekdayOf = (civilDate: string): string => {
|
||||
const [year, month, day] = civilDate.split('-').map(Number)
|
||||
return ptBrWeekdays[new Date(Date.UTC(year, month - 1, day)).getUTCDay()] ?? ''
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadEvents: vi.fn(),
|
||||
|
|
@ -44,7 +44,11 @@ type ScheduleMutationInfo = {
|
|||
}
|
||||
|
||||
type CalendarProps = {
|
||||
datesSet: (info: { startStr: string; endStr: string }) => void
|
||||
datesSet: (info: {
|
||||
startStr: string
|
||||
endStr: string
|
||||
view: { type: string; calendar: { getDate: () => Date } }
|
||||
}) => void
|
||||
eventDrop: (info: ScheduleMutationInfo) => void
|
||||
eventResize: (info: ScheduleMutationInfo) => void
|
||||
dateClick: (info: DateClickInfo) => void
|
||||
|
|
@ -57,6 +61,10 @@ vi.mock('@fullcalendar/react', () => {
|
|||
datesSet({
|
||||
startStr: '2026-08-03T03:00:00.000Z',
|
||||
endStr: '2026-08-10T03:00:00.000Z',
|
||||
view: {
|
||||
type: 'timeGridWeek',
|
||||
calendar: { getDate: () => new Date('2026-08-03T03:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
}, [datesSet])
|
||||
|
||||
|
|
@ -100,17 +108,34 @@ vi.mock('@fullcalendar/react', () => {
|
|||
})
|
||||
|
||||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||
|
||||
const renderAgenda = (props: { state?: object } = {}) =>
|
||||
render(
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<ActivityAgenda state={props.state ?? {}} />
|
||||
</CampaignPageChromeProvider>,
|
||||
)
|
||||
|
||||
describe('ActivityAgenda schedule failures', () => {
|
||||
// C101 — ActivityAgenda reads the viewport signal (useIsMobileMeasured);
|
||||
// jsdom has no matchMedia, so the harness pins the desktop layout (the
|
||||
// toolbar and the calendar body are what these tests exercise).
|
||||
beforeEach(() => {
|
||||
stubMatchMedia()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reverts a transport failure and releases the event for another attempt', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
mocks.reschedule.mockRejectedValue(new Error('transport failed'))
|
||||
render(<ActivityAgenda state={{}} />)
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mover' }))
|
||||
|
|
@ -126,7 +151,7 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
it('reverts a refused resize with the safe server message', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
mocks.reschedule.mockResolvedValue({ ok: false, message: 'Remarcação recusada.' })
|
||||
render(<ActivityAgenda state={{}} />)
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redimensionar' }))
|
||||
|
|
@ -137,7 +162,7 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
|
||||
it('abre a criação inline no clique de um slot vazio, sem navegar', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
render(<ActivityAgenda state={{}} />)
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
|
|
@ -153,7 +178,7 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
|
||||
it('não exibe o aviso de janela vazia nem o botão de criação acima do calendário', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
render(<ActivityAgenda state={{}} />)
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
|
||||
|
|
|
|||
125
tests/unit/activityAgendaPeriodLabel.unit.spec.ts
Normal file
125
tests/unit/activityAgendaPeriodLabel.unit.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { activityAgendaPeriodLabel, activityAgendaViewFromFcId } from '@/utilities/activityUi'
|
||||
|
||||
describe('activityAgendaPeriodLabel', () => {
|
||||
it('rotula o dia pelo dia e mês', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'day',
|
||||
'2026-08-09T00:00:00-03:00',
|
||||
'2026-08-10T00:00:00-03:00',
|
||||
'2026-08-09T00:00:00-03:00',
|
||||
),
|
||||
).toBe('9 Agosto')
|
||||
})
|
||||
|
||||
it('rotula a semana pelo intervalo visível (segunda a domingo) no mesmo mês', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'week',
|
||||
'2026-08-03T00:00:00-03:00',
|
||||
'2026-08-10T00:00:00-03:00',
|
||||
'2026-08-03T00:00:00-03:00',
|
||||
),
|
||||
).toBe('3–9 Agosto')
|
||||
})
|
||||
|
||||
it('escreve os dois meses quando a semana cruza o limite do mês', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'week',
|
||||
'2026-07-27T00:00:00-03:00',
|
||||
'2026-08-03T00:00:00-03:00',
|
||||
'2026-07-27T00:00:00-03:00',
|
||||
),
|
||||
).toBe('27 Julho – 2 Agosto')
|
||||
})
|
||||
|
||||
it('cruza o ano sem omitir o dia final', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'week',
|
||||
'2026-12-28T00:00:00-03:00',
|
||||
'2027-01-04T00:00:00-03:00',
|
||||
'2026-12-28T00:00:00-03:00',
|
||||
),
|
||||
).toBe('28 Dezembro – 3 Janeiro')
|
||||
})
|
||||
|
||||
it('rotula o mês pelo mês ativo, não pelo início do grid (domingo antecedente)', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'month',
|
||||
'2026-07-26T00:00:00-03:00',
|
||||
'2026-09-01T00:00:00-03:00',
|
||||
'2026-08-01',
|
||||
),
|
||||
).toBe('Agosto')
|
||||
})
|
||||
|
||||
it('aceita o anchor como data civil pura (a forma do runtime, sem timezone)', () => {
|
||||
// O runtime passa `formatBahiaCivilDate(getDate())` — "2026-09-01" sem
|
||||
// offset. Parsear como instante UTC a deslocaria para 31 de agosto na
|
||||
// Bahia (meia-noite UTC = 21:00 do dia anterior).
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'month',
|
||||
'2026-08-31T00:00:00-03:00',
|
||||
'2026-10-01T00:00:00-03:00',
|
||||
'2026-09-01',
|
||||
),
|
||||
).toBe('Setembro')
|
||||
})
|
||||
|
||||
it('mantém "Agenda" na lista, onde os dias aparecem no corpo', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'list',
|
||||
'2026-08-01T00:00:00-03:00',
|
||||
'2026-09-01T00:00:00-03:00',
|
||||
'2026-08-01T00:00:00-03:00',
|
||||
),
|
||||
).toBe('Agenda')
|
||||
})
|
||||
|
||||
it('retorna null para intervalo ilegível', () => {
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'day',
|
||||
'não-é-data',
|
||||
'2026-08-10T00:00:00-03:00',
|
||||
'2026-08-09T00:00:00-03:00',
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'week',
|
||||
'2026-08-03T00:00:00-03:00',
|
||||
'não-é-data',
|
||||
'2026-08-03T00:00:00-03:00',
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
activityAgendaPeriodLabel(
|
||||
'month',
|
||||
'2026-08-01T00:00:00-03:00',
|
||||
'2026-09-01T00:00:00-03:00',
|
||||
'não-é-data',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('activityAgendaViewFromFcId', () => {
|
||||
it('espelha activityAgendaViewFcId para as quatro vistas', () => {
|
||||
expect(activityAgendaViewFromFcId('timeGridWeek')).toBe('week')
|
||||
expect(activityAgendaViewFromFcId('timeGridDay')).toBe('day')
|
||||
expect(activityAgendaViewFromFcId('dayGridMonth')).toBe('month')
|
||||
expect(activityAgendaViewFromFcId('listMonth')).toBe('list')
|
||||
})
|
||||
|
||||
it('retorna null para um view id desconhecido', () => {
|
||||
expect(activityAgendaViewFromFcId('multiMonthYear')).toBeNull()
|
||||
})
|
||||
})
|
||||
197
tests/unit/useAgendaSwipeNavigation.unit.spec.tsx
Normal file
197
tests/unit/useAgendaSwipeNavigation.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createElement, useRef, type MutableRefObject } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
|
||||
|
||||
const pointerEvent = (
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
||||
init: {
|
||||
pointerId?: number
|
||||
pointerType?: string
|
||||
isPrimary?: boolean
|
||||
clientX: number
|
||||
clientY: number
|
||||
},
|
||||
) =>
|
||||
new PointerEvent(type, {
|
||||
pointerId: init.pointerId ?? 1,
|
||||
pointerType: init.pointerType ?? 'touch',
|
||||
isPrimary: init.isPrimary ?? true,
|
||||
bubbles: true,
|
||||
clientX: init.clientX,
|
||||
clientY: init.clientY,
|
||||
})
|
||||
|
||||
const Harness = ({
|
||||
enabled = true,
|
||||
blockRef,
|
||||
onSwipe,
|
||||
onReady,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
blockRef?: MutableRefObject<boolean>
|
||||
onSwipe: (direction: 'next' | 'prev') => void
|
||||
onReady?: (suppressDateClickRef: MutableRefObject<boolean>) => void
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const internalBlockRef = useRef(false)
|
||||
const { suppressDateClickRef } = useAgendaSwipeNavigation({
|
||||
containerRef,
|
||||
enabled,
|
||||
blockRef: blockRef ?? internalBlockRef,
|
||||
onSwipe,
|
||||
})
|
||||
if (onReady) onReady(suppressDateClickRef)
|
||||
return createElement('div', { ref: containerRef, 'data-testid': 'agenda' })
|
||||
}
|
||||
|
||||
const swipe = (container: HTMLElement, fromX: number, toX: number, y = 100) => {
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: fromX, clientY: y }))
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: toX, clientY: y }))
|
||||
fireEvent(window, pointerEvent('pointerup', { clientX: toX, clientY: y }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useAgendaSwipeNavigation', () => {
|
||||
it('navega para o período seguinte com um arrasto horizontal à esquerda', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('navega para o período anterior com um arrasto horizontal à direita', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 100, 300)
|
||||
expect(onSwipe).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('não navega abaixo do limiar de 48px', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 260)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('não navega num arrasto diagonal (dominância vertical)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
fireEvent(
|
||||
container.firstElementChild as HTMLElement,
|
||||
pointerEvent('pointerdown', { clientX: 300, clientY: 100 }),
|
||||
)
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 200, clientY: 180 }))
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignora mouse (a toolbar do desktop continua mandando)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
fireEvent(
|
||||
container,
|
||||
pointerEvent('pointerdown', { pointerType: 'mouse', clientX: 300, clientY: 100 }),
|
||||
)
|
||||
fireEvent(
|
||||
window,
|
||||
pointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, clientY: 100 }),
|
||||
)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('não inicia gesto com enabled=false', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { enabled: false, onSwipe }))
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('abandona o gesto quando o FullCalendar começa um drag de evento (blockRef)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const blockRef = { current: false }
|
||||
const { container } = render(createElement(Harness, { blockRef, onSwipe }))
|
||||
fireEvent(
|
||||
container.firstElementChild as HTMLElement,
|
||||
pointerEvent('pointerdown', { clientX: 300, clientY: 100 }),
|
||||
)
|
||||
// a flag vira true a meio do gesto (long-press do FC venceu)
|
||||
blockRef.current = true
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marca suppressDateClickRef no consumo (o swipe não abre o inline create)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
let suppressDateClickRef: MutableRefObject<boolean> | undefined
|
||||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
|
||||
)
|
||||
expect(suppressDateClickRef).toBeDefined()
|
||||
expect(suppressDateClickRef!.current).toBe(false)
|
||||
swipe(container as HTMLElement, 300, 100)
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
})
|
||||
|
||||
it('marca suppressDateClickRef já no claim de 12px (um flick sub-limiar não abre o inline create)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
let suppressDateClickRef: MutableRefObject<boolean> | undefined
|
||||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
|
||||
)
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
// flick de 20px: cruza o claim (12px) mas não o limiar de navegação
|
||||
// (48px) — o claim vive no touchmove não-passivo.
|
||||
fireEvent.touchMove(agenda, { touches: [{ clientX: 280, clientY: 100 }] })
|
||||
expect(onSwipe).not.toHaveBeenCalled()
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
})
|
||||
|
||||
it('limpa suppressDateClickRef no próximo pointerdown (o tap genuíno continua abrindo o create)', () => {
|
||||
const onSwipe = vi.fn()
|
||||
let suppressDateClickRef: MutableRefObject<boolean> | undefined
|
||||
const { container } = render(
|
||||
createElement(Harness, { onSwipe, onReady: (ref) => (suppressDateClickRef = ref) }),
|
||||
)
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
expect(suppressDateClickRef!.current).toBe(true)
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 200, clientY: 100 }))
|
||||
expect(suppressDateClickRef!.current).toBe(false)
|
||||
})
|
||||
|
||||
it('aceita pen além de touch', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
fireEvent(
|
||||
agenda,
|
||||
pointerEvent('pointerdown', { pointerType: 'pen', clientX: 300, clientY: 100 }),
|
||||
)
|
||||
fireEvent(
|
||||
window,
|
||||
pointerEvent('pointermove', { pointerType: 'pen', clientX: 100, clientY: 100 }),
|
||||
)
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('reseta o gesto no pointercancel e aceita o próximo swipe', () => {
|
||||
const onSwipe = vi.fn()
|
||||
const { container } = render(createElement(Harness, { onSwipe }))
|
||||
const agenda = container.firstElementChild as HTMLElement
|
||||
// o browser tomou o gesto (scroll vertical): pointercancel
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointercancel', { clientX: 300, clientY: 180 }))
|
||||
// o swipe seguinte funciona normalmente
|
||||
fireEvent(agenda, pointerEvent('pointerdown', { clientX: 300, clientY: 100 }))
|
||||
fireEvent(window, pointerEvent('pointermove', { clientX: 100, clientY: 100 }))
|
||||
expect(onSwipe).toHaveBeenCalledWith('next')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue