From 9cb8fef98df95383571e014dfa56eef46d4bf92e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 20:38:04 -0300 Subject: [PATCH] =?UTF-8?q?feat(C110+):=20preview=20do=20arrasto=20?= =?UTF-8?q?=E2=80=94=20indicadores=20ancorados=20ao=20frame,=20gradiente?= =?UTF-8?q?=20deduplicado,=20e2e=20do=20direction=20prev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/plans/c110-ux-pos-critique-impl.md | 73 ++++++++++++ .../campaign/activity/ActivityAgenda.css | 40 ++++--- .../activity/ActivityAgendaSwipePreview.tsx | 91 +++++++-------- tests/e2e/campaignAgendaMobile.e2e.spec.ts | 37 +++++++ .../activityAgendaSwipePreview.unit.spec.tsx | 104 ++++++++++++++++++ 5 files changed, 286 insertions(+), 59 deletions(-) create mode 100644 docs/plans/c110-ux-pos-critique-impl.md create mode 100644 tests/unit/activityAgendaSwipePreview.unit.spec.tsx diff --git a/docs/plans/c110-ux-pos-critique-impl.md b/docs/plans/c110-ux-pos-critique-impl.md new file mode 100644 index 00000000..6c12599d --- /dev/null +++ b/docs/plans/c110-ux-pos-critique-impl.md @@ -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. diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css index 95b40809..778b8305 100644 --- a/src/components/campaign/activity/ActivityAgenda.css +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -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, diff --git a/src/components/campaign/activity/ActivityAgendaSwipePreview.tsx b/src/components/campaign/activity/ActivityAgendaSwipePreview.tsx index 7caeb977..79a48f89 100644 --- a/src/components/campaign/activity/ActivityAgendaSwipePreview.tsx +++ b/src/components/campaign/activity/ActivityAgendaSwipePreview.tsx @@ -82,52 +82,55 @@ export const ActivityAgendaSwipePreview = ({ )} {label ? {label} : 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' ? ( -
- {view === 'week' ? ( -
- {weekInitials.map((initial) => ( - {initial} - ))} -
- ) : null} +
+
+ {view === 'week' ? ( +
+ {weekInitials.map((initial) => ( + {initial} + ))} +
+ ) : null} +
+ {visibleEvents.map((event) => { + if (view === 'month') { + const cell = monthCellPosition(range.start, event.startAt) + if (!cell) return null + return ( + + ) + } + return ( + + ) + })}
- ) : null} - - {visibleEvents.map((event) => { - if (view === 'month') { - const cell = monthCellPosition(range.start, event.startAt) - if (!cell) return null - return ( - - ) - } - if (view === 'list') { - return ( - - {event.title} - - ) - } - return ( - - ) - })} + ) : ( + visibleEvents.map((event) => ( + + {event.title} + + )) + )}
) diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts index 6846c894..2d5af3e4 100644 --- a/tests/e2e/campaignAgendaMobile.e2e.spec.ts +++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts @@ -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, diff --git a/tests/unit/activityAgendaSwipePreview.unit.spec.tsx b/tests/unit/activityAgendaSwipePreview.unit.spec.tsx new file mode 100644 index 00000000..7b7c78f4 --- /dev/null +++ b/tests/unit/activityAgendaSwipePreview.unit.spec.tsx @@ -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 => ({ + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + expect(nextContainer.querySelector('svg.lucide-chevron-right')).not.toBeNull() + expect(nextContainer.querySelector('svg.lucide-chevron-left')).toBeNull() + + const { container: prevContainer } = render( + , + ) + expect(prevContainer.querySelector('svg.lucide-chevron-left')).not.toBeNull() + expect(prevContainer.querySelector('svg.lucide-chevron-right')).toBeNull() + }) +})