mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
b4278f1632
commit
9cb8fef98d
5 changed files with 286 additions and 59 deletions
73
docs/plans/c110-ux-pos-critique-impl.md
Normal file
73
docs/plans/c110-ux-pos-critique-impl.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
104
tests/unit/activityAgendaSwipePreview.unit.spec.tsx
Normal file
104
tests/unit/activityAgendaSwipePreview.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue