Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Especificação — navegação e densidade mobile

## Problem Statement

Na visão mobile do ArchTime, o conteúdo do dashboard fica muito próximo da borda da tela, escondendo a malha arquitetônica de fundo. Os cartões de resumo de Hoje, Semana e Mês ocupam toda a largura e carregam espaçamento vertical herdado que deixa uma área vazia desproporcional ao conteúdo. O novo menu de navegação também parece lento ao abrir, mesmo sem travar funcionalmente.

## Solution

Em telas mobile, aumentar o recuo lateral do conteúdo de 16 px para 32 px, mantendo os breakpoints maiores inalterados. Compactar somente os três cartões de resumo para que tenham largura intrínseca e removam o padding/gap estrutural excedente no mobile. Acelerar exclusivamente a animação do drawer de navegação mobile, preservando a composição e a acessibilidade do Sheet existente.

## User Stories

1. Como arquiteta usando o PWA em um celular, quero mais área visível da malha atrás do conteúdo para que a tela não pareça colada na borda.
2. Como usuária mobile, quero que o recuo lateral continue consistente ao rolar entre as páginas autenticadas para que a navegação não pareça saltar.
3. Como usuária, quero que o cartão de Hoje tenha apenas o espaço necessário para seus dados para que eu identifique o resumo sem uma grande área vazia.
4. Como usuária, quero que os cartões de Semana e Mês sigam a mesma densidade do cartão de Hoje para que o resumo tenha ritmo visual coerente.
5. Como usuária, quero que os valores e rótulos completos continuem legíveis mesmo quando um saldo for maior que o habitual.
6. Como usuária com tema claro ou escuro, quero que os cartões compactos preservem contraste e os tokens de aparência para que a compactação não crie regressão visual.
7. Como usuária, quero que o menu hamburguer apareça rapidamente após o toque para que a navegação pareça responsiva.
8. Como usuária que navega por teclado ou leitor de tela, quero que o menu continue usando dialog, foco, rótulos e fechamento existentes para que a melhora visual não reduza acessibilidade.
9. Como usuária em tela desktop ou tablet, quero que a grade de resumo e os espaçamentos existentes não mudem para que a correção permaneça estritamente mobile.
10. Como mantenedora, quero E2E cobrindo a abertura do menu e a geometria mobile para que o feedback não regrida em mudanças futuras.

## Implementation Decisions

- O breakpoint mobile é o único alvo de mudança de layout; os breakpoints a partir de `sm` mantêm os valores atuais.
- O recuo lateral mobile passa a usar o próximo múltiplo de 4 px que dobra o valor atual: 32 px.
- Os cartões de resumo usam largura intrínseca e removem, somente no mobile, o padding e o gap estrutural herdados do componente Card. Seus filhos continuam responsáveis pelo espaçamento interno e preservam conteúdo completo.
- O menu permanece um Sheet do Radix/shadcn. A animação continua composta por transform e opacidade, com entrada de 200 ms e saída de 150 ms, em vez dos 500/300 ms herdados. O drawer usa `will-change: transform` somente enquanto está montado, para ajudar a composição em aparelhos menos potentes.
- Quando o sistema pede redução de movimento, o drawer e seu overlay abrem sem animação. A mudança é opt-in por preferência de acessibilidade e não altera a experiência normal em desktop ou mobile.
- A alteração não cria uma nova navegação, não muda as opções do menu e não altera tokens de cor ou o comportamento de tema.

## Testing Decisions

- O seam principal é o caminho do usuário no navegador mobile autenticado: abrir o menu, navegar por uma opção e confirmar a página de destino.
- O E2E verifica recuo lateral computado, ausência de overflow horizontal e que cada cartão de resumo mobile cabe no contêiner, sem rolagem interna nem padding estrutural vazio.
- O E2E cobre 390 px, 320 px e o breakpoint `sm`, para garantir que a correção continua estritamente mobile; em 320 px, também injeta saldos excepcionalmente longos para validar quebra de linha sem corte.
- O E2E verifica que a animação aberta do Sheet usa duração de no máximo 200 ms e que a preferência `prefers-reduced-motion` zera a animação do drawer e do overlay. A métrica de FPS não é estável em browser headless; composição e duração são a fronteira verificável.
- A suíte existente de visualização autenticada é usada como referência para os temas e a viewport de 390 px.

## Out of Scope

- Alterações de layout em desktop ou tablet.
- Novos itens, rotas ou estado para o menu.
- Troca de biblioteca de drawer, alterações de tema, ou redesenho do ActivityPanel.
- Alterações de dados de Sessão, fila offline ou API.

## Further Notes

O feedback da usuária é específico de tela pequena. A compactação é deliberada para os três resumos, que são métricas de leitura rápida, e não deve ser aplicada por padrão a todos os Cards do produto.
9 changes: 6 additions & 3 deletions e2e/clock-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { sameOriginHeaders } from './helpers/request'

// Mutating end-to-end of the activity feature against the REAL prod DB, made safe:
// - skips entirely if the user has a real open session (won't touch it);
Expand All @@ -21,8 +22,9 @@ function brtWall(iso: string, addMinutes = 0): string {
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
}

test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ({ page }) => {
test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ({ page, baseURL }) => {
test.setTimeout(120_000) // fluxo longo: várias navegações/loads + poll do servidor
const headers = sameOriginHeaders(baseURL)
const activeBefore = await (await page.request.get('/api/clock/active')).json().catch(() => null)
test.skip(Boolean(activeBefore), 'usuário tem uma sessão aberta real — pulando teste mutante')

Expand Down Expand Up @@ -72,6 +74,7 @@ test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async (
const outWall = brtWall(active!.clockIn, 0)
const patchRes = await page.request.patch(`/api/clock/${entryId}`, {
data: { clockInAt: inWall, clockOutAt: outWall, activityType: 'modelagem' },
headers,
})
expect(patchRes.ok(), `PATCH de horários deveria ter sucesso (${patchRes.status()})`).toBeTruthy()
const rowHHmm = inWall.slice(11, 16)
Expand Down Expand Up @@ -105,8 +108,8 @@ test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async (
entryId = a?.id
}
if (entryId) {
await page.request.put(`/api/clock/${entryId}`).catch(() => {})
await page.request.delete(`/api/clock/${entryId}`).catch(() => {})
await page.request.put(`/api/clock/${entryId}`, { headers }).catch(() => {})
await page.request.delete(`/api/clock/${entryId}`, { headers }).catch(() => {})
}
} catch {
// best-effort
Expand Down
22 changes: 13 additions & 9 deletions e2e/clockout-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test'
import { sameOriginHeaders } from './helpers/request'

type FailureMode = 'network' | 'response-lost' | 'server'

Expand All @@ -12,15 +13,17 @@ async function activeSession(page: Page) {

async function exerciseRetry(
page: Page,
baseURL: string | undefined,
failureMode: FailureMode
) {
const headers = sameOriginHeaders(baseURL)
const before = await activeSession(page)
test.skip(Boolean(before), 'usuário tem uma sessão aberta real — pulando teste mutante')

let entryId: string | undefined
let primaryClockOutAt: string | undefined
try {
const clockIn = await page.request.post('/api/clock', { data: { projectId: null } })
const clockIn = await page.request.post('/api/clock', { data: { projectId: null }, headers })
expect(clockIn.ok(), `clock-in de teste deveria criar uma Sessão (${clockIn.status()})`).toBeTruthy()
entryId = (await clockIn.json()).id

Expand Down Expand Up @@ -72,6 +75,7 @@ async function exerciseRetry(
expect(primaryClockOutAt, 'o PUT primário deve carregar o horário do clique').toBeTruthy()
const idempotent = await page.request.put(`/api/clock/${entryId}`, {
data: { clockOutAt: '2000-01-01T00:00:00.000Z' },
headers,
})
expect(idempotent.ok(), `PUT idempotente deveria reler a Sessão (${idempotent.status()})`).toBeTruthy()
expect((await idempotent.json()).clockOut).toBe(primaryClockOutAt)
Expand All @@ -82,20 +86,20 @@ async function exerciseRetry(
if (entryId) {
await page.unroute(`**/api/clock/${entryId}`)
// PUT idempotente fecha apenas a Sessão criada neste teste se o retry falhar cedo.
await page.request.put(`/api/clock/${entryId}`).catch(() => {})
await page.request.delete(`/api/clock/${entryId}`).catch(() => {})
await page.request.put(`/api/clock/${entryId}`, { headers }).catch(() => {})
await page.request.delete(`/api/clock/${entryId}`, { headers }).catch(() => {})
}
}
}

test('clock-out recupera automaticamente de um 5xx sem a rede cair', async ({ page }) => {
await exerciseRetry(page, 'server')
test('clock-out recupera automaticamente de um 5xx sem a rede cair', async ({ page, baseURL }) => {
await exerciseRetry(page, baseURL, 'server')
})

test('clock-out recupera automaticamente de uma falha de rede', async ({ page }) => {
await exerciseRetry(page, 'network')
test('clock-out recupera automaticamente de uma falha de rede', async ({ page, baseURL }) => {
await exerciseRetry(page, baseURL, 'network')
})

test('clock-out preserva o horário quando a escrita conclui mas a resposta se perde', async ({ page }) => {
await exerciseRetry(page, 'response-lost')
test('clock-out preserva o horário quando a escrita conclui mas a resposta se perde', async ({ page, baseURL }) => {
await exerciseRetry(page, baseURL, 'response-lost')
})
4 changes: 4 additions & 0 deletions e2e/helpers/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Cabeçalho que faz mutações do APIRequestContext obedecerem ao CSRF da aplicação. */
export function sameOriginHeaders(baseURL: string | undefined): Record<string, string> {
return { Origin: new URL(baseURL ?? 'http://localhost:3000').origin }
}
189 changes: 189 additions & 0 deletions e2e/mobile-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { test, expect, type Page } from '@playwright/test'
import { applyAppearance } from './helpers/appearance'

const dashboardHeading = { name: 'Ponto' }
const extremeMinutes = Number.MAX_SAFE_INTEGER

async function openDashboard(page: Page, width: number) {
await page.setViewportSize({ width, height: 844 })
await applyAppearance(page, { dark: false, blueprint: true })
await page.goto('/dashboard')
await expect(page.getByRole('heading', dashboardHeading)).toBeVisible({ timeout: 20_000 })
}

async function expectSummaryCardsFit(page: Page, contentWidth: number) {
for (const card of [
page.getByTestId('summary-card-today'),
page.getByTestId('summary-card-week'),
page.getByTestId('summary-card-month'),
]) {
await expect(card).toBeVisible()
const metrics = await card.evaluate((element) => {
const style = getComputedStyle(element)
return {
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
gap: Number.parseFloat(style.rowGap),
paddingBottom: Number.parseFloat(style.paddingBottom),
paddingTop: Number.parseFloat(style.paddingTop),
scrollHeight: element.scrollHeight,
scrollWidth: element.scrollWidth,
width: element.getBoundingClientRect().width,
}
})

// Com os dados normais da conta de teste, os três resumos devem continuar
// intrínsecos — ocupar toda a coluna reintroduziria a área vazia reportada.
expect(metrics.width).toBeLessThan(contentWidth)
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight)
expect(metrics.gap).toBe(0)
expect(metrics.paddingTop).toBe(0)
expect(metrics.paddingBottom).toBe(0)
}
}

async function stubExtremeSummary(page: Page) {
const balance = {
actualMinutes: extremeMinutes,
balanceMinutes: -extremeMinutes,
expectedMinutes: extremeMinutes,
}
await page.route('**/api/clock/summary', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
entries: [],
month: {
...balance,
cumulativeBalance: extremeMinutes,
showCumulativeBalance: true,
},
sessionCount: 0,
today: balance,
totalMinutes: extremeMinutes,
week: balance,
}),
})
})
}

test.describe('Dashboard mobile', () => {
test('preserva a malha lateral e compacta os três resumos em 390 px', async ({ page }) => {
await openDashboard(page, 390)

const shell = page.locator('[data-page-ready="true"]')
const shellMetrics = await shell.evaluate((element) => {
const style = getComputedStyle(element)
const paddingLeft = Number.parseFloat(style.paddingLeft)
const paddingRight = Number.parseFloat(style.paddingRight)
return {
contentWidth: element.clientWidth - paddingLeft - paddingRight,
paddingLeft,
paddingRight,
}
})

expect(shellMetrics.paddingLeft).toBe(32)
expect(shellMetrics.paddingRight).toBe(32)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true)
await expectSummaryCardsFit(page, shellMetrics.contentWidth)
})

test('mantém conteúdo utilizável sem overflow em uma tela estreita de 320 px', async ({ page }) => {
await openDashboard(page, 320)

const shell = page.locator('[data-page-ready="true"]')
const shellMetrics = await shell.evaluate((element) => {
const style = getComputedStyle(element)
const paddingLeft = Number.parseFloat(style.paddingLeft)
const paddingRight = Number.parseFloat(style.paddingRight)
return {
contentWidth: element.clientWidth - paddingLeft - paddingRight,
paddingLeft,
paddingRight,
}
})

expect(shellMetrics.paddingLeft).toBe(32)
expect(shellMetrics.paddingRight).toBe(32)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true)
await expectSummaryCardsFit(page, shellMetrics.contentWidth)
})

test('quebra saldos excepcionalmente longos sem cortar o conteúdo em 320 px', async ({ page }) => {
await stubExtremeSummary(page)
await openDashboard(page, 320)

for (const card of [
page.getByTestId('summary-card-today'),
page.getByTestId('summary-card-week'),
page.getByTestId('summary-card-month'),
]) {
await expect(card).toBeVisible()
const metrics = await card.evaluate((element) => ({
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
scrollHeight: element.scrollHeight,
scrollWidth: element.scrollWidth,
}))
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight)
}

expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true)
})

test('abre um menu rápido, preserva foco e navega pelo drawer', async ({ page }) => {
await openDashboard(page, 390)

const trigger = page.getByRole('button', { name: 'Abrir menu' })
await trigger.click()

const drawer = page.locator('[data-slot="sheet-content"]')
await expect(drawer).toBeVisible()
const duration = await drawer.evaluate((element) => Number.parseFloat(getComputedStyle(element).animationDuration) * 1_000)
expect(duration).toBeLessThanOrEqual(200)
await expect.poll(() => drawer.evaluate((element) => element.contains(document.activeElement))).toBe(true)

await page.keyboard.press('Escape')
await expect(drawer).toBeHidden()
await expect(trigger).toBeFocused()

await trigger.click()
await expect(drawer).toBeVisible()
await drawer.getByRole('link', { name: 'Histórico' }).click()
await expect(page).toHaveURL(/\/historico/)
await expect(page.getByRole('heading', { name: 'Histórico' })).toBeVisible({ timeout: 15_000 })
})
})

test.describe('Dashboard a partir de sm', () => {
test('restaura o espaçamento existente no limiar do breakpoint', async ({ page }) => {
await openDashboard(page, 640)

const padding = await page.locator('[data-page-ready="true"]').evaluate((element) => {
const style = getComputedStyle(element)
return { left: Number.parseFloat(style.paddingLeft), right: Number.parseFloat(style.paddingRight) }
})

expect(padding).toEqual({ left: 24, right: 24 })
})
})

test.describe('Menu com redução de movimento', () => {
test('remove a animação do drawer e do overlay', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(true)
await openDashboard(page, 390)
await page.getByRole('button', { name: 'Abrir menu' }).click()

const drawer = page.locator('[data-slot="sheet-content"]')
const overlay = page.locator('[data-slot="sheet-overlay"]')
await expect(drawer).toBeVisible()
await expect(overlay).toBeVisible()

await expect(drawer).toHaveCSS('animation-duration', '0s')
await expect(overlay).toHaveCSS('animation-duration', '0s')
})
})
19 changes: 19 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ try {
// .env.local optional (e.g. CI provides env directly)
}

// Os E2E locais compartilham o banco de produção. Quando o clone ainda só tem
// ENTRY_HASH_SECRET, espelhe o keyring de bootstrap da ADR 0005 para conseguir
// verificar os hashes históricos já identificados. Um keyring explícito (inclusive
// parcial) sempre prevalece, para que a validação de boot continue encontrando erro.
const hasExplicitHashKeyring = [
'ENTRY_HASH_KEY_IDS',
'ENTRY_HASH_ACTIVE_KEY_ID',
'ENTRY_HASH_LEGACY_KEY_ID',
].some((name) => process.env[name] !== undefined) ||
Object.keys(process.env).some((name) => name.startsWith('ENTRY_HASH_SECRET_'))

if (!hasExplicitHashKeyring && process.env.ENTRY_HASH_SECRET) {
const bootstrapKeyId = 'k2026-07'
process.env.ENTRY_HASH_KEY_IDS = bootstrapKeyId
process.env.ENTRY_HASH_ACTIVE_KEY_ID = bootstrapKeyId
process.env.ENTRY_HASH_LEGACY_KEY_ID = bootstrapKeyId
process.env.ENTRY_HASH_SECRET_K2026_07 = process.env.ENTRY_HASH_SECRET
}

/** True when no base URL is set or it points at a loopback host (so we auto-start dev). */
function isLocalBaseURL(baseURL: string | undefined): boolean {
if (!baseURL) return true
Expand Down
6 changes: 3 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,9 @@ html.theme-switching *::after {
.stagger > *:nth-child(6) { animation-delay: calc(var(--delay-stagger) * 5); }

/* ─── Reduced motion ───
Intentionally NOT honored: animations are core to the product experience and
play regardless of the OS prefers-reduced-motion setting. The motion/react
counterpart is <MotionConfig reducedMotion="never"> in providers.tsx. */
O produto mantém suas animações de feedback via MotionConfig, mas navegação
modal tem alternativa instantânea no próprio Sheet para respeitar a preferência
do sistema sem mudar a experiência normal. */

/* ═══════════════════════════════════════════════════════════════
DENSITY SYSTEM
Expand Down
Loading
Loading