refactor(C110): simplify — helpers de dominância/clamp, seed e2e generalizado, CSS sem regras mortas

This commit is contained in:
Francisco Solla 2026-08-10 14:42:54 -03:00
parent b23a323f83
commit 3b81431d39
9 changed files with 190 additions and 242 deletions

View file

@ -77,3 +77,9 @@ flowchart LR
- [ ] Aceite de produto da intenção ainda coberto (grid segue o dedo; adjacente revelado com chevron + label + quadro; eventos assíncronos no preview; snap-back < 48 px; commit ≥ 48 px com transição suave; reduced-motion instantâneo; desktop, tap C91 e long-press C15 intactos; vista de dia sem create pós-arrasto)
- [ ] Invariantes AGENTS/engineering-standards (sem migration/sem prod/sem Consent novo; copy pt-BR / identificadores em inglês; hook dono do gesto com contrato unit-testado; sem twin de componentes)
- [ ] Testes de domínio: unit do helper adjacente + label parity; unit do hook (contrato novo); e2e mobile (snap-back, preview mid-drag com eventos, commit) + existentes verdes; gate:fast e gate:push verdes
## Débitos deferidos (pós-/simplify; nenhum com score ≥4 — nada virou Issue)
- **Dots do mês ancorados no content box, não no frame do grid** (o painel posiciona os dots contra o bloco cheio do preview — chevron/label inclusos — e o frame é um skeleton independente). Polimento visual dentro da aproximação aceita pelo aceite; gatilho de revisita: se o mês ganhar mais uso no mobile ou a crítica de produto apontar o desalinhamento.
- **Gradiente de colunas duplicado no CSS** (base do frame + camada do mês) — custom property traria mais indireção que as ~5 linhas; descartado.
- **Chevron segue o canvas do gate (aponta para o lado revelado)** — se a leitura "direção da mudança" do aceite prevalecer na prática, é flip de 1 linha (ChevronLeft/Right) no `ActivityAgendaSwipePreview`.

View file

@ -95,11 +95,6 @@
padding: 0.625rem;
}
.activity-agenda-swipe-preview--next .activity-agenda-swipe-preview-content {
align-items: flex-start;
text-align: left;
}
.activity-agenda-swipe-preview--prev .activity-agenda-swipe-preview-content {
align-items: flex-end;
text-align: right;
@ -135,8 +130,6 @@
var(--border) calc(14.285% - 1px),
var(--border) 14.285%
);
background-size: 100% 100%;
position: relative;
}
.activity-agenda-swipe-frame[data-view='day'] {
@ -189,21 +182,22 @@
accept's async path): bars by time proportion for day/week, dots per date
for month, rows for the list. Approximate on purpose — the commit renders
the real grid. */
.activity-agenda-swipe-event {
.activity-agenda-swipe-event,
.activity-agenda-swipe-dot {
position: absolute;
left: 0;
width: 62%;
height: 0.3125rem;
border-radius: 0.1875rem;
background: color-mix(in oklab, var(--primary) 72%, transparent);
}
.activity-agenda-swipe-event {
left: 0;
width: 62%;
height: 0.3125rem;
}
.activity-agenda-swipe-dot {
position: absolute;
width: 9%;
height: 0.375rem;
border-radius: 0.1875rem;
background: color-mix(in oklab, var(--primary) 72%, transparent);
}
.activity-agenda-swipe-list-row {

View file

@ -403,7 +403,6 @@ export const ActivityAgenda = ({
const handleSwipePreviewEnd = useCallback(() => {
swipePreviewKeyRef.current = null
setSwipePreview(null)
setSwipePreviewEvents([])
}, [])
useEffect(() => {
@ -596,6 +595,8 @@ export const ActivityAgenda = ({
headingLevel={2}
/>
{/* `visibleRange &&` narrows the type for the preview's `view` prop
(the memo already guarantees it is non-null). */}
{swipePreview && visibleRange && adjacentPreviewRange ? (
<ActivityAgendaSwipePreview
view={visibleRange.view}

View file

@ -23,6 +23,7 @@ import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
*/
const SLOT_START_MINUTES = 7 * 60
const SLOT_END_MINUTES = 22 * 60
const SLOT_SPAN_MINUTES = SLOT_END_MINUTES - SLOT_START_MINUTES
const MAX_PREVIEW_EVENTS = 6
const weekInitials = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'] as const
@ -38,11 +39,8 @@ const timedTopPercent = (startAt: string): number => {
const match = /T(\d{2}):(\d{2})/.exec(civil)
if (!match) return 0
const minutes = Number(match[1]) * 60 + Number(match[2])
const clamped = Math.min(
Math.max(minutes - SLOT_START_MINUTES, 0),
SLOT_END_MINUTES - SLOT_START_MINUTES,
)
return (clamped / (SLOT_END_MINUTES - SLOT_START_MINUTES)) * 100
const clamped = Math.min(Math.max(minutes - SLOT_START_MINUTES, 0), SLOT_SPAN_MINUTES)
return (clamped / SLOT_SPAN_MINUTES) * 100
}
const monthCellPosition = (
@ -86,7 +84,8 @@ export const ActivityAgendaSwipePreview = ({
{/* 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). */}
accepted degradation of the intention). The day view keeps just
the time bands — a single-day header would mislabel the columns. */}
{view !== 'list' ? (
<div className="activity-agenda-swipe-frame" data-view={view}>
{view === 'week' ? (

View file

@ -74,6 +74,17 @@ const AGENDA_SWIPE_EXIT_MS = 180
type AgendaSwipeDirection = 'next' | 'prev'
/** C110 — the grid's displacement is clamped to the claimed direction: it
* never crosses its starting point, and the release threshold reads this
* clamped position (the finger may have reversed past the origin — the grid
* did not move). */
const clampDisplacement = (dx: number, direction: AgendaSwipeDirection): number =>
direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
/** Swipe-vs-scroll discriminator: the vertical component dominates the drag. */
const isVerticalDrag = (dx: number, dy: number): boolean =>
Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
type AgendaSwipeGesture = {
pointerId: number
startX: number
@ -150,10 +161,10 @@ export const useAgendaSwipeNavigation = ({
/** C110 — the live transform: follows the finger, clamped to the claimed
* direction (the grid never crosses its starting point). */
const applyTransform = useCallback(
(dx: number, direction: AgendaSwipeDirection | null) => {
(dx: number, direction: AgendaSwipeDirection) => {
const container = containerRef.current
if (!container) return
const clamped = direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
const clamped = clampDisplacement(dx, direction)
container.style.transform = clamped === 0 ? '' : `translateX(${clamped}px)`
},
[containerRef],
@ -247,10 +258,7 @@ export const useAgendaSwipeNavigation = ({
const dx = touch.clientX - gesture.startX
const dy = touch.clientY - gesture.startY
if (gesture.direction === null) {
if (
Math.abs(dx) > AGENDA_SWIPE_CLAIM_PX &&
Math.abs(dx) > Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
) {
if (Math.abs(dx) > AGENDA_SWIPE_CLAIM_PX && !isVerticalDrag(dx, dy)) {
// 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
@ -268,6 +276,8 @@ export const useAgendaSwipeNavigation = ({
// preventDefault because its commit ended the gesture within a move or
// two; the release decision keeps the gesture alive through many.)
event.preventDefault()
gesture.lastDx = dx
gesture.lastDy = dy
applyTransform(dx, gesture.direction)
},
[applyTransform, claimGesture],
@ -289,14 +299,14 @@ export const useAgendaSwipeNavigation = ({
gesture.lastDy = dy
if (gesture.direction === null) {
if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) {
if (isVerticalDrag(dx, dy)) {
// Vertical drag (scrolling the timeline, rescheduling): never claim.
gestureRef.current = null
}
return
}
if (Math.abs(dx) < Math.abs(dy) * AGENDA_SWIPE_DOMINANCE) {
if (isVerticalDrag(dx, dy)) {
// C110 — a claimed gesture turned vertical (scroll takeover): abandon,
// snap back, close the preview. The browser is scrolling now — the
// grid must not stay displaced.
@ -329,20 +339,16 @@ export const useAgendaSwipeNavigation = ({
if (gesture.direction !== null) {
const dx = gesture.lastDx
const dy = gesture.lastDy
const displaced = gesture.direction === 'next' ? Math.min(dx, 0) : Math.max(dx, 0)
if (
Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX &&
Math.abs(dx) >= Math.abs(dy) * AGENDA_SWIPE_DOMINANCE
) {
settleTransform()
onSwipePreviewEndRef.current?.()
const displaced = clampDisplacement(dx, gesture.direction)
if (Math.abs(displaced) >= AGENDA_SWIPE_THRESHOLD_PX && !isVerticalDrag(dx, dy)) {
endWithoutCommit()
onSwipeRef.current(gesture.direction)
return
}
endWithoutCommit()
}
},
[endWithoutCommit, settleTransform],
[endWithoutCommit],
)
useEffect(() => {

View file

@ -111,35 +111,34 @@ export const activityAgendaAdjacentPeriod = (
range: { start: string; end: string; anchorDate: string },
direction: 'next' | 'prev',
): { start: string; end: string; anchorDate: string } | null => {
const shiftDays = direction === 'next' ? 1 : -1
const shiftMonths = direction === 'next' ? 1 : -1
const shift = direction === 'next' ? 1 : -1
const shiftRangeByDays = (days: number) => {
const start = new Date(range.start)
const end = new Date(range.end)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null
const shiftedStart = new Date(start.getTime() + days * 86_400_000).toISOString()
const shiftedStart = new Date(start.getTime() + days * 86_400_000)
return {
start: shiftedStart,
start: shiftedStart.toISOString(),
end: new Date(end.getTime() + days * 86_400_000).toISOString(),
anchorDate: formatBahiaCivilDate(new Date(start.getTime() + days * 86_400_000)),
anchorDate: formatBahiaCivilDate(shiftedStart),
}
}
if (view === 'day' || view === 'week') {
return shiftRangeByDays(shiftDays * (view === 'day' ? 1 : 7))
return shiftRangeByDays(shift * (view === 'day' ? 1 : 7))
}
const anchor = civilDateAnchor(range.anchorDate)
const anchor = civilDateParts(range.anchorDate)
if (!anchor || anchor.day !== 1) return null
const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shiftMonths)
const shiftedAnchorParts = shiftCivilMonth(anchor.year, anchor.month, shift)
const shiftedAnchor = `${shiftedAnchorParts.year}-${pad(shiftedAnchorParts.month)}-01`
if (view === 'list') {
const nextMonth = shiftCivilMonth(shiftedAnchorParts.year, shiftedAnchorParts.month, 1)
return {
start: civilInstantAtBahia(shiftedAnchor),
end: civilInstantAtBahia(`${nextMonth.year}-${pad(nextMonth.month)}-01`),
start: allDayStartInstant(shiftedAnchor),
end: allDayStartInstant(`${nextMonth.year}-${pad(nextMonth.month)}-01`),
anchorDate: shiftedAnchor,
}
}
@ -148,42 +147,14 @@ export const activityAgendaAdjacentPeriod = (
// and spans at most 6 weeks.
const start = subtractBahiaCivilDays(shiftedAnchor, mondayOffsetOf(shiftedAnchor))
return {
start: civilInstantAtBahia(start),
end: civilInstantAtBahia(subtractBahiaCivilDays(start, -42)),
start: allDayStartInstant(start),
end: allDayStartInstant(subtractBahiaCivilDays(start, -42)),
anchorDate: shiftedAnchor,
}
}
/**
* Midnight of a Bahia civil date as a UTC instant. Bahia is fixed at UTC-3
* (no DST since 2019), so the explicit `-03:00` offset is stable — the same
* convention FullCalendar's own range strings use.
*/
const civilInstantAtBahia = (civilDate: string): string =>
new Date(`${civilDate}T00:00:00-03:00`).toISOString()
const pad = (value: number): string => String(value).padStart(2, '0')
/** Full civil-date anchor (`aaaa-mm-dd`) with day/month/year — the runtime
* passes `formatBahiaCivilDate(getDate())`, so the year is always present. */
const civilDateAnchor = (
civilDate: string,
): { year: number; month: number; day: 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 { year, month, day }
}
const shiftCivilMonth = (
year: number,
month: number,
@ -200,7 +171,7 @@ const mondayOffsetOf = (civilDate: string): number => {
return (weekday + 6) % 7
}
const civilDateParts = (civilDate: string): { day: number; month: number } | null => {
const civilDateParts = (civilDate: string): { year: number; month: number; day: number } | null => {
const [year, month, day] = civilDate.split('-').map(Number)
if (
!Number.isInteger(year) ||
@ -213,7 +184,7 @@ const civilDateParts = (civilDate: string): { day: number; month: number } | nul
) {
return null
}
return { day, month }
return { year, month, day }
}
const civilDateLabel = (civilDate: string): string | null => {

View file

@ -43,48 +43,39 @@ const openMonthView = async (page: Page) => {
* `/campanha`. Paced, it stays a drag the agenda consumes via its
* non-passive touchmove preventDefault, while still finishing far below
* FullCalendar's 650ms long-press (which would start a reschedule).
*
* C110 — `inspectAfterMove` pauses the gesture mid-drag (after the 12px
* claim) so a test can observe the live preview before the release decides.
*/
const dispatchTouchDrag = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
{
moveDelayMs = 25,
inspectAfterMove,
}: { moveDelayMs?: number; inspectAfterMove?: () => Promise<void> } = {},
) => {
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 new Promise((resolve) => setTimeout(resolve, moveDelayMs))
if (i === 2) await inspectAfterMove?.()
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
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 new Promise((resolve) => setTimeout(resolve, 25))
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
/**
* C110 — like `touchSwipe`, but pauses mid-gesture (after the 12px claim) for
* `inspect` to observe the live preview before the release decides.
*/
const touchDragWithInspection = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
inspect: () => Promise<void>,
) => {
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 new Promise((resolve) => setTimeout(resolve, 40))
if (i === 2) await inspect()
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
) => dispatchTouchDrag(cdp, box, fromRatio, toRatio)
const agendaSwipePreview = (page: Page) => page.locator('.activity-agenda-swipe-preview')
@ -93,26 +84,43 @@ const tapAt = async (cdp: CDPSession, x: number, y: number) => {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise<string> => {
/**
* Seeds agenda activities and logs the coordinator in. `rows` maps a civil
* date + hour to an activity title; `fixtures.value` keeps titles/slugs
* unique per test run — literal titles would collide on the unique slug
* between consecutive tests of this spec.
*/
const seedAgendaActivities = async (
campaign: CampaignE2EFixture,
page: Page,
rows: Array<[civilDate: string, hour: string, title: string]>,
): Promise<void> => {
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,
})
for (const [civilDate, hour, rawTitle] of rows) {
const startAt = parseBahiaDateTimeInput(`${civilDate}T${hour}`)
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(rawTitle),
tags: ['Comício'],
status: 'confirmado',
startAt,
municipality: municipality.id,
}),
depth: 0,
})
}
await campaign.login(page, coordinator.email!, coordinator.password)
}
const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise<string> => {
const civilDate = formatBahiaCivilDate(new Date())
await seedAgendaActivities(campaign, page, [[civilDate, '10:00', 'Compromisso mobile nativo']])
return civilDate
}
@ -381,32 +389,12 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit
campaign: CampaignE2EFixture,
page: Page,
): Promise<{ today: string; tomorrow: string }> => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const today = formatBahiaCivilDate(new Date())
const tomorrow = civilDatePlusDays(today, 1)
for (const [civilDate, hour, title] of [
await seedAgendaActivities(campaign, page, [
[today, '10:00', 'Compromisso mobile hoje'],
[tomorrow, '11:00', 'Compromisso mobile amanhã'],
] as const) {
const startAt = parseBahiaDateTimeInput(`${civilDate}T${hour}`)
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title,
tags: ['Comício'],
status: 'confirmado',
startAt,
municipality: municipality.id,
}),
depth: 0,
})
}
await campaign.login(page, coordinator.email!, coordinator.password)
])
return { today, tomorrow }
}
@ -429,22 +417,26 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit
// A meio do gesto (após o claim de 12px): o grid acompanhou o dedo e o
// painel do período adjacente está revelado na borda — chevron, rótulo do
// período seguinte e os eventos do adjacente entrando conforme carregam.
await touchDragWithInspection(cdp, agendaBox, 0.8, 0.3, async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('svg.activity-agenda-swipe-chevron')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(dayLabelFor(tomorrow))
await expect
.poll(
() => preview.locator('.activity-agenda-swipe-event').count(),
{ timeout: 15_000 },
await dispatchTouchDrag(cdp, agendaBox, 0.8, 0.3, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('svg.activity-agenda-swipe-chevron')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
.toBeGreaterThan(0)
// O grid deslocado: o transform vivo acompanhou o dedo.
const transform = await page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
)
expect(transform).not.toBe('none')
await expect
.poll(() => preview.locator('.activity-agenda-swipe-event').count(), {
timeout: 15_000,
})
.toBeGreaterThan(0)
// O grid deslocado: o transform vivo acompanhou o dedo.
const transform = await page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
)
expect(transform).not.toBe('none')
},
})
// Soltou acima do limiar: o período adjacente assume a tela e o preview some.
@ -461,7 +453,7 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit
campaign,
page,
}) => {
const { today } = await seedTodayAndTomorrowActivities(campaign, page)
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
@ -475,12 +467,15 @@ test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit
// Arrasto de ~47px (0.9 → 0.78 em 390px): cruza o claim de 12px — o
// preview abre — mas o soltar fica abaixo dos 48px do limiar.
await touchDragWithInspection(cdp, agendaBox, 0.9, 0.78, async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(civilDatePlusDays(today, 1)),
)
await dispatchTouchDrag(cdp, agendaBox, 0.9, 0.78, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
},
})
// Snap-back: o título não muda e o grid voltou ao repouso.

View file

@ -132,6 +132,15 @@ describe('activityAgendaAdjacentPeriod', () => {
end: '2026-08-11T03:00:00.000Z',
anchorDate: '2026-08-10',
})
// paridade: o label do preview (range adjacente) == título pós-commit
expect(
activityAgendaPeriodLabel(
'day',
'2026-08-10T03:00:00.000Z',
'2026-08-11T03:00:00.000Z',
'2026-08-10',
),
).toBe('10 Agosto')
expect(
activityAgendaAdjacentPeriod(
'day',
@ -165,6 +174,15 @@ describe('activityAgendaAdjacentPeriod', () => {
end: '2026-08-17T03:00:00.000Z',
anchorDate: '2026-08-10',
})
// paridade: o label do preview == título pós-commit
expect(
activityAgendaPeriodLabel(
'week',
'2026-08-10T03:00:00.000Z',
'2026-08-17T03:00:00.000Z',
'2026-08-10',
),
).toBe('10–16 Agosto')
})
it('cruza o ano na semana e o dia (28 Dezembro → 4 Janeiro)', () => {
@ -245,24 +263,26 @@ describe('activityAgendaAdjacentPeriod', () => {
})
it('desloca a lista em um mês inteiro (range do mês adjacente)', () => {
expect(
activityAgendaAdjacentPeriod(
'list',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-01',
},
'next',
),
).toEqual({
const next = activityAgendaAdjacentPeriod(
'list',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-01',
},
'next',
)
expect(next).toEqual({
start: '2026-09-01T03:00:00.000Z',
end: '2026-10-01T03:00:00.000Z',
anchorDate: '2026-09-01',
})
expect(activityAgendaPeriodLabel('list', next!.start, next!.end, next!.anchorDate)).toBe(
'Agenda',
)
})
it('retorna null quando o anchor do mês não é o 1º ou o intervalo é ilegível', () => {
it('retorna null quando o anchor do mês não é o 1º, é malformado, ou o intervalo é ilegível', () => {
expect(
activityAgendaAdjacentPeriod(
'month',
@ -274,6 +294,17 @@ describe('activityAgendaAdjacentPeriod', () => {
'next',
),
).toBeNull()
expect(
activityAgendaAdjacentPeriod(
'month',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: 'não-é-data',
},
'next',
),
).toBeNull()
expect(
activityAgendaAdjacentPeriod(
'day',
@ -286,60 +317,6 @@ describe('activityAgendaAdjacentPeriod', () => {
),
).toBeNull()
})
it('label do preview == título pós-commit (paridade dia/semana/mês/lista)', () => {
const day = activityAgendaAdjacentPeriod(
'day',
{
start: '2026-08-09T00:00:00-03:00',
end: '2026-08-10T00:00:00-03:00',
anchorDate: '2026-08-09',
},
'next',
)
expect(activityAgendaPeriodLabel('day', day!.start, day!.end, day!.anchorDate)).toBe(
'10 Agosto',
)
const week = activityAgendaAdjacentPeriod(
'week',
{
start: '2026-07-27T00:00:00-03:00',
end: '2026-08-03T00:00:00-03:00',
anchorDate: '2026-07-27',
},
'next',
)
expect(activityAgendaPeriodLabel('week', week!.start, week!.end, week!.anchorDate)).toBe(
'3–9 Agosto',
)
const month = activityAgendaAdjacentPeriod(
'month',
{
start: '2026-07-26T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-01',
},
'next',
)
expect(activityAgendaPeriodLabel('month', month!.start, month!.end, month!.anchorDate)).toBe(
'Setembro',
)
const list = activityAgendaAdjacentPeriod(
'list',
{
start: '2026-08-01T00:00:00-03:00',
end: '2026-09-01T00:00:00-03:00',
anchorDate: '2026-08-01',
},
'next',
)
expect(activityAgendaPeriodLabel('list', list!.start, list!.end, list!.anchorDate)).toBe(
'Agenda',
)
})
})
describe('activityAgendaViewFromFcId', () => {

View file

@ -51,8 +51,7 @@ const Harness = ({
if (onReady) onReady(suppressDateClickRef)
return createElement('div', { ref: containerRef, 'data-testid': 'agenda' })
}
const agendaOf = (container: HTMLElement): HTMLElement =>
container.firstElementChild as HTMLElement
const agendaOf = (container: HTMLElement): HTMLElement => container.firstElementChild as HTMLElement
/** Dispatches a cancellable touchmove and returns the event for assertion. */
const touchMoveAt = (agenda: HTMLElement, x: number, y: number): Event => {