diff --git a/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md index 3ba5f72d..85263b0a 100644 --- a/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md +++ b/docs/plans/c110-agenda-mobile-feedback-arrasto-impl.md @@ -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`. diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css index 30854dd4..95b40809 100644 --- a/src/components/campaign/activity/ActivityAgenda.css +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -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 { diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index 70842eb3..268c9b96 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -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 ? ( { 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' ? (
{view === 'week' ? ( diff --git a/src/components/campaign/activity/useAgendaSwipeNavigation.ts b/src/components/campaign/activity/useAgendaSwipeNavigation.ts index a89afe8f..12a8b0b2 100644 --- a/src/components/campaign/activity/useAgendaSwipeNavigation.ts +++ b/src/components/campaign/activity/useAgendaSwipeNavigation.ts @@ -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(() => { diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 71f869eb..2b86ca34 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -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 => { diff --git a/tests/e2e/campaignAgendaMobile.e2e.spec.ts b/tests/e2e/campaignAgendaMobile.e2e.spec.ts index 86e26630..6846c894 100644 --- a/tests/e2e/campaignAgendaMobile.e2e.spec.ts +++ b/tests/e2e/campaignAgendaMobile.e2e.spec.ts @@ -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 } = {}, +) => { + 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, -) => { - 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 => { +/** + * 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 => { 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 => { + 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. diff --git a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts index 97e8c127..6f780b6e 100644 --- a/tests/unit/activityAgendaPeriodLabel.unit.spec.ts +++ b/tests/unit/activityAgendaPeriodLabel.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx index 8e7b5f87..566702b4 100644 --- a/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx +++ b/tests/unit/useAgendaSwipeNavigation.unit.spec.tsx @@ -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 => {