feat(C110+): preview do arrasto — indicadores ancorados ao frame, gradiente deduplicado, e2e do direction prev

- scene posicionado único (.activity-agenda-swipe-scene) envolve frame + dots/bars:
  percentuais resolvem contra o box do frame, não o content (chevron/label inclusos)
- width: 100% no scene (fix do /simplify: align-items flex-end do prev colapsava a 1px)
- --swipe-frame-columns deduplica o gradiente de 14.285% (2 call sites)
- unit test estrutural do preview (month/day/week/list, chevron por direção)
- e2e novo: direction prev com frame visível (pina o fix do colapso)
This commit is contained in:
Francisco Solla 2026-08-10 20:38:04 -03:00
parent b4278f1632
commit 9cb8fef98d
5 changed files with 286 additions and 59 deletions

View file

@ -0,0 +1,73 @@
# Impl: C110+ — UX pós-critique: refinamentos do preview do arrasto da agenda (chevron, dots do mês, gradiente)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #618
Intenção: docs/plans/c110-ux-pos-critique.md
Appetite restante: ~0,5 dia eng fill-in (herdado; o lote cabe com folga)
## Leitura da intenção
- **Outcome:** colher os débitos pós-/simplify do C110 que ficaram abaixo do limiar de Issue individual, agrupados na superfície do preview do arrasto: (1) validar a leitura do chevron "direção da mudança" vs. canvas; (2) dots do mês ancorados ao box do frame (não ao bloco cheio do conteúdo); (3) deduplicar o gradiente de colunas de 14.285% via custom property — **somente se o lote já mexer nesse CSS** (senão descartar).
- **O que NÃO negociar:** aceite do C110 intacto (grid segue o dedo; preview aproximado — não é pixel-perfect; direção do gesto travada no claim; transform imperativo no hook); nada de redesenho do preview (Opção 2 do aceite — só seta — é rabbit hole); animar mais superfícies da agenda é anti-goal; desktop/FC intactos.
- **O que reavaliar:** o item 2 fala só em "dots do mês", mas as **barras day/week têm exatamente o mesmo defeito** (posicionam contra o content box, que inclui chevron+label — um evento das 07:00 renderiza sobre o rótulo). Corrigir pelo mesmo container posicionado único é grátis (mesma estrutura) e corrige o mesmo bug — decisão de escopo documentada abaixo (Opção A inclui, B restringe a dots).
## Abordagem recomendada
```mermaid
flowchart LR
A[ActivityAgendaSwipePreview: container posicionado único .scene] --> B[frame + dots/bars ancoram no box do frame]
B --> C[--swipe-frame-columns deduplica gradiente]
D[chevron: decisão de produto no gate] --> E[manter canvas (recomendado) ou flip 1 linha]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — um container posicionado único `.activity-agenda-swipe-scene` (flex: 1, position: relative, filho do content após chevron/label) envolve o frame **e todos os indicadores posicionados** (dots do mês + bars day/week); os percentuais passam a resolver contra o box do frame, matando o desalinhamento vertical dos dots da primeira fileira e, de graça, o mesmo defeito das bars. Gradiente: custom property `--swipe-frame-columns` no `.activity-agenda-swipe-frame` (2 call sites reais, mesmo seletor-family, ~2 linhas — o lote mexe nesse bloco CSS ao introduzir o scene).
**Rejeitadas:** B — envolver só head+frame+dots (literal da intenção) e deixar as bars contra o content: mantém o mesmo bug nas bars day/week com dois sistemas de ancoragem no mesmo content (mais complexo que o fix completo); C — não fazer nada (deixar débitos registrados): a intenção é o lote, o custo é trivial.
### Componentes / mudanças
- **`ActivityAgendaSwipePreview.tsx`** (`src/components/campaign/activity/`): introduz `.activity-agenda-swipe-scene` como wrapper posicionado único contendo o frame e os indicadores posicionados (dots/bars); list rows continuam no fluxo do content (não são posicionadas). Chevron: **decisão do gate** (ver abaixo) — se flip, `ChevronRight`/`ChevronLeft` trocam de lado (1 linha cada).
- **`ActivityAgenda.css`**: `.activity-agenda-swipe-scene { position: relative; flex: 1; min-height: 0 }`; frame passa de `flex: 1` para `width: 100%; height: 100%` (o scene é o flex item agora); `--swipe-frame-columns` declarada no `.activity-agenda-swipe-frame` e consumida na base + `[data-view='month']`.
- **Migration:** sem migration. **Access/Consent:** nenhum.
- **UI:** Impeccable B — encaixe na superfície já entregue, sem shape novo (a intenção já classificou assim); verificação no browser do alinhamento dots×frame no mês.
### Dados → forma
- N/A — nenhuma mudança de dados; a "forma" (frame skeleton, dots, bars) já foi aceita no C110.
## Fases verificáveis
1. **Estrutura + CSS** — scene wrapper + ancoragem de dots/bars + `--swipe-frame-columns`; verificação visual no browser (mês: dots na fileira 1 não tocam o rótulo; day/week: bars alinhadas às faixas do frame).
2. **Chevron (se flip aprovado)** — troca de ícone; e2e não asserta direção (só visibilidade do `svg.activity-agenda-swipe-chevron`), então sem mudança de teste.
3. **Unit test estrutural do preview** — spec novo pequeno (`activityAgendaSwipePreview.unit.spec.tsx`): render com view=month → dots são descendentes do `.activity-agenda-swipe-scene`; chevron esquerda/direita conforme `direction`; pina a correção contra regressão.
4. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` na entrega.
## Rabbit holes / Não escopo (engenharia)
- Redesenho do preview (Opção 2 do aceite); compensar a borda de 1px do frame nos offsets dos dots (ruído de `calc` para 1px num preview assumidamente aproximado — gatilho de revisita: se a crítica visual apontar); medir/animar mais superfícies; qualquer mudança no hook/gesto.
## Débitos deferidos (pós-/simplify; nenhum registra Issue)
- **Barras do week todas na coluna "Seg"** (`left: 0`, sem o dia da semana — enganoso com o head de 7 colunas; fix barato: `left: weekdayIndex * 14.285%`). **Gatilho:** se a crítica visual apontar as barras da semana na coluna errada (S6).
- **Dots do mês ignoram o offset do 1º dia** (`dayIndex % 7`, o grid real usa `firstDay=1` — a semana 1 fica ~1 banda acima). Dentro da aproximação aceita. **Gatilho:** se o preview do mês ganhar mais uso no mobile ou a crítica visual apontar o desalinhamento (S7).
- **Borda de 1px do frame vs indicadores em 0%** — decisão registrada (sem compensação; gatilho de revisita se a crítica visual apontar).
- **Validação da leitura do chevron com usuário da persona** — decisão de produto do gate: manter a leitura do canvas (aponta para o lado revelado), convenção de carousel; flip é 1 linha se a validação futura apontar o contrário.
## Riscos e mitigação
- **Overflow do scene no flex**: `min-height: 0` evita estourar o content quando o frame é `height: 100%`.
- **e2e existente** (`campaignAgendaMobile.e2e.spec.ts`): asserta `svg.activity-agenda-swipe-chevron` visível e `.activity-agenda-swipe-event` count > 0 — ambos sobrevivem intactos à reestruturação (classes não mudam).
- **Mudança visual das bars day/week** (alinhamento ao frame em vez do content): é a correção do mesmo bug, mas altera o look — mitigação: verificação no browser na fase 1 antes de fechar.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (chevron validado/decidido; dots ancoram no frame; gradiente deduplicado)
- [ ] Invariantes AGENTS/engineering-standards (sem migration/sem prod/sem Consent novo; copy pt-BR / identificadores em inglês; sem twin de componentes)
- [ ] Testes: unit estrutural do preview (fase 3); e2e existente da agenda mobile verde; gate:fast e gate:push verdes
## Decisões de engenharia
- **Container posicionado único inclui bars (não só dots)** — Opções: A (scene com frame+dots+bars) | B (só dots) | C (nada). Recomendação: A — mesmo defeito, mesmo fix, zero custo incremental, um único sistema de ancoragem. Rejeitadas: B (deixa bug idêntico nas bars, dois sistemas de ancoragem); C (débito não colhido, contra a intenção do lote).
- **Gradiente via `--swipe-frame-columns`** — a intenção condicionava a custom property a "se o lote já mexer nesse CSS"; o lote mexe no mesmo bloco (scene/frame) → condição satisfeita; 2 call sites reais.
- **Sem compensação da borda de 1px** — barata de reverter; preview é aproximado por aceite.

View file

@ -114,22 +114,38 @@
white-space: nowrap;
}
/* The abstract grid frame ("quadro do grid"): a week/day header plus faint
/* C110+ — the positioned scene: the frame skeleton AND the event indicators
(month dots, day/week bars) anchor to the frame's own box. Previously the
indicators resolved their percentages against the whole content block —
chevron/label included — so the first row could sit on top of the label. */
.activity-agenda-swipe-scene {
position: relative;
flex: 1;
min-height: 0;
/* C110+ — the prev content aligns its flex items to the end; without an
explicit width the scene (a new flex item) collapses to fit-content on
that variant. */
width: 100%;
}
/* The abstract grid frame ("quadro do grid"): a week 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(
--swipe-frame-columns: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(14.285% - 1px),
var(--border) calc(14.285% - 1px),
var(--border) 14.285%
);
width: 100%;
height: 100%;
border: 1px solid var(--border);
border-radius: 0.375rem;
overflow: hidden;
opacity: 0.55;
background-image: var(--swipe-frame-columns);
}
.activity-agenda-swipe-frame[data-view='day'] {
@ -144,13 +160,7 @@
.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%
),
var(--swipe-frame-columns),
repeating-linear-gradient(
to bottom,
transparent 0,

View file

@ -82,52 +82,55 @@ export const ActivityAgendaSwipePreview = ({
)}
{label ? <span className="activity-agenda-swipe-label">{label}</span> : null}
{/* C110 — the abstract grid frame; the list view has no columns to
suggest, so it degrades to chevron + label + event rows (the
accepted degradation of the intention). The day view keeps just
the time bands — a single-day header would mislabel the columns. */}
{/* C110+ — the positioned indicators (dots/bars) anchor to the frame's
own box: they live in the same `.activity-agenda-swipe-scene`
wrapper as the skeleton, so their percentages resolve against the
frame, not the whole content block (chevron/label included). The
list view has no frame, so its rows stay in the content flow. */}
{view !== 'list' ? (
<div className="activity-agenda-swipe-frame" data-view={view}>
{view === 'week' ? (
<div className="activity-agenda-swipe-frame-head">
{weekInitials.map((initial) => (
<span key={initial}>{initial}</span>
))}
</div>
) : null}
<div className="activity-agenda-swipe-scene">
<div className="activity-agenda-swipe-frame" data-view={view}>
{view === 'week' ? (
<div className="activity-agenda-swipe-frame-head">
{weekInitials.map((initial) => (
<span key={initial}>{initial}</span>
))}
</div>
) : null}
</div>
{visibleEvents.map((event) => {
if (view === 'month') {
const cell = monthCellPosition(range.start, event.startAt)
if (!cell) return null
return (
<span
key={event.id}
className="activity-agenda-swipe-dot"
style={{
left: `${cell.column * (100 / 7)}%`,
top: `${cell.row * (100 / 6)}%`,
}}
/>
)
}
return (
<span
key={event.id}
className="activity-agenda-swipe-event"
style={
event.allDay ? { top: '2%' } : { top: `${timedTopPercent(event.startAt)}%` }
}
/>
)
})}
</div>
) : null}
{visibleEvents.map((event) => {
if (view === 'month') {
const cell = monthCellPosition(range.start, event.startAt)
if (!cell) return null
return (
<span
key={event.id}
className="activity-agenda-swipe-dot"
style={{
left: `${cell.column * (100 / 7)}%`,
top: `${cell.row * (100 / 6)}%`,
}}
/>
)
}
if (view === 'list') {
return (
<span key={event.id} className="activity-agenda-swipe-list-row">
{event.title}
</span>
)
}
return (
<span
key={event.id}
className="activity-agenda-swipe-event"
style={event.allDay ? { top: '2%' } : { top: `${timedTopPercent(event.startAt)}%` }}
/>
)
})}
) : (
visibleEvents.map((event) => (
<span key={event.id} className="activity-agenda-swipe-list-row">
{event.title}
</span>
))
)}
</div>
</div>
)

View file

@ -449,6 +449,43 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('o arrasto para o período anterior também revela o quadro do grid (prev, C110+)', async ({
campaign,
page,
}) => {
const { today } = await seedTodayAndTomorrowActivities(campaign, page)
const yesterday = civilDatePlusDays(today, -1)
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.')
// C110+ — pinha o direction `prev`: o content do preview alinha no fim
// (flex-end) e o scene precisa de largura explícita para não colapsar.
await dispatchTouchDrag(cdp, agendaBox, 0.3, 0.8, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-frame')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(yesterday),
)
},
})
await expect(campaignPageChrome(page, dayLabelFor(yesterday))).toBeVisible({
timeout: 15_000,
})
await expect(agendaSwipePreview(page)).toHaveCount(0)
})
test('soltar abaixo do limiar volta ao período atual (snap-back) sem navegar', async ({
campaign,
page,

View file

@ -0,0 +1,104 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ActivityAgendaSwipePreview } from '@/components/campaign/activity/ActivityAgendaSwipePreview'
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
afterEach(() => {
cleanup()
})
const event = (overrides: Partial<ActivityAgendaEvent> = {}): ActivityAgendaEvent => ({
id: 1,
title: 'Caminhada',
href: '/campanha/atividades/caminhada',
tags: [],
status: 'confirmado',
deputyPresent: false,
allDay: false,
startAt: '2026-08-10T09:00:00-03:00',
endAt: null,
municipality: null,
locality: null,
canReschedule: true,
...overrides,
})
const range = {
start: '2026-08-03T00:00:00-03:00',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-03T00:00:00-03:00',
}
describe('ActivityAgendaSwipePreview', () => {
it('ancora os dots do mês dentro da cena posicionada junto ao frame (C110+)', () => {
const { container } = render(
<ActivityAgendaSwipePreview view="month" direction="next" range={range} events={[event()]} />,
)
const scene = container.querySelector('.activity-agenda-swipe-scene')
expect(scene).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-frame')).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-dot')).not.toBeNull()
})
it('ancora as barras de dia dentro da cena posicionada junto ao frame (C110+)', () => {
const { container } = render(
<ActivityAgendaSwipePreview view="day" direction="next" range={range} events={[event()]} />,
)
const scene = container.querySelector('.activity-agenda-swipe-scene')
expect(scene).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-frame')).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-event')).not.toBeNull()
})
it('mantém o cabeçalho de iniciais na semana dentro da cena (C110+)', () => {
const { container } = render(
<ActivityAgendaSwipePreview view="week" direction="next" range={range} events={[event()]} />,
)
const scene = container.querySelector('.activity-agenda-swipe-scene')
expect(scene).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-frame-head')).not.toBeNull()
expect(scene!.querySelector('.activity-agenda-swipe-event')).not.toBeNull()
})
it('deixa as linhas da lista no fluxo do conteúdo, fora da cena (C110+)', () => {
const { container } = render(
<ActivityAgendaSwipePreview view="list" direction="next" range={range} events={[event()]} />,
)
expect(container.querySelector('.activity-agenda-swipe-scene')).toBeNull()
const row = container.querySelector('.activity-agenda-swipe-list-row')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('Caminhada')
})
it('não desenha dot para evento fora do range de 42 dias do mês', () => {
const { container } = render(
<ActivityAgendaSwipePreview
view="month"
direction="next"
range={range}
events={[event({ startAt: '2026-10-01T09:00:00-03:00' })]}
/>,
)
expect(container.querySelector('.activity-agenda-swipe-dot')).toBeNull()
})
it('aponta o chevron para o lado revelado: direita no próximo, esquerda no anterior', () => {
const { container: nextContainer } = render(
<ActivityAgendaSwipePreview view="month" direction="next" range={range} events={[]} />,
)
expect(nextContainer.querySelector('svg.lucide-chevron-right')).not.toBeNull()
expect(nextContainer.querySelector('svg.lucide-chevron-left')).toBeNull()
const { container: prevContainer } = render(
<ActivityAgendaSwipePreview view="month" direction="prev" range={range} events={[]} />,
)
expect(prevContainer.querySelector('svg.lucide-chevron-left')).not.toBeNull()
expect(prevContainer.querySelector('svg.lucide-chevron-right')).toBeNull()
})
})