From abced29e85032a6156357e16386608a4097453ee Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 15 Oct 2026 11:09:25 +0200 Subject: [PATCH 1/3] feat(plans): drag draft schedule dates and intervals --- .../__tests__/plan-schedule-preview.test.ts | 124 +++++++++++++++ frontend/src/plan-schedule-preview.ts | 143 +++++++++++++++--- frontend/src/styles/plans.css | 8 + .../tests-e2e/plan-schedule-preview.spec.ts | 98 +++++++++++- 4 files changed, 351 insertions(+), 22 deletions(-) diff --git a/frontend/src/__tests__/plan-schedule-preview.test.ts b/frontend/src/__tests__/plan-schedule-preview.test.ts index 6dc637c5..d9f3edd1 100644 --- a/frontend/src/__tests__/plan-schedule-preview.test.ts +++ b/frontend/src/__tests__/plan-schedule-preview.test.ts @@ -155,3 +155,127 @@ test('rechecks operation-time UTC midnight and refuses stale reset without repla expect(point(2).getAttribute('aria-label')).toContain('2026-11-04'); expect(document.getElementById('preview-date-error')?.textContent).toMatch(/future/); }); + + +const pointerPreview = (input: PlanSchedulePreviewInput = draft) => { + openPlanSchedulePreview(input, parent); + const svg = document.querySelector('#plan-schedule-preview svg')!; + let captured = false; + Object.assign(svg, { + getScreenCTM: () => ({ inverse: () => ({}) }), + createSVGPoint: () => ({ x: 0, y: 0, matrixTransform() { return { x: this.x, y: this.y }; } }), + setPointerCapture: jest.fn(() => { captured = true; }), + hasPointerCapture: () => captured, + releasePointerCapture: jest.fn(() => { captured = false; }), + }); + const send = (type: string, target: Element, x: number, id = 1, primary = true): void => { + target.dispatchEvent(Object.assign(new Event(type, { bubbles: true, cancelable: true }), + { pointerId: id, isPrimary: primary, button: 0, clientX: x, clientY: 0 })); + }; + const x = (index: number): number => Number(point(index).querySelector('circle')!.getAttribute('cx')); + return { svg, send, x, day: 544 / ((input.preset === 'immediate' ? 0 : (Math.floor(100 / Number(input.stepPercent)) - 1) * 7) + 8) }; +}; + +test('drags dates from off-center grabs without jumping and keeps the frozen scale, shares and nodes', () => { + const { svg, send, x, day } = pointerPreview(); + const input = field('preview-selected-date'), node = point(1), origin = x(1) + 18; + send('pointerdown', node, origin); + Object.assign(svg, { getScreenCTM: () => { throw new Error('Coordinate frame must stay frozen'); } }); + send('pointermove', svg, origin + day / 4); + expect(node.getAttribute('aria-label')).toContain('2026-10-27'); + send('pointermove', svg, origin + day); + expect(node.getAttribute('aria-label')).toContain('2026-10-28'); + expect(input.value).toBe('2026-10-28'); + expect(document.activeElement).toBe(node); + expect(input.disabled).toBe(true); + expect(x(1)).toBeCloseTo(origin - 18 + day); + send('pointerup', svg, origin + day); + expect(input.disabled).toBe(false); + expect(document.activeElement).toBe(node); + expect(point(1)).toBe(node); + expect(point(1).getAttribute('aria-label')).toContain('target 40%'); + expect(parent.querySelector('input')?.value).toBe('unchanged'); +}); + +test.each(['pointercancel', 'lostpointercapture', 'Escape'])('rolls back a captured draft on %s and closes only on a subsequent Escape', reason => { + const { svg, send, x, day } = pointerPreview(), origin = x(1); + send('pointerdown', point(1), origin); + send('pointermove', svg, origin + day); + expect(field('preview-selected-date').value).toBe('2026-10-28'); + if (reason === 'Escape') point(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + else send(reason, svg, origin + day); + expect(field('preview-selected-date').value).toBe('2026-10-27'); + expect(document.getElementById('preview-date-changes')?.textContent).toContain('(0)'); + expect(svg.releasePointerCapture).toHaveBeenCalled(); + expect(document.getElementById('plan-schedule-preview')).not.toBeNull(); + point(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.getElementById('plan-schedule-preview')).toBeNull(); +}); + +test('redistributes endpoint gestures, focuses their owner before disabling inputs and supplies keyboard activation', () => { + const { svg, send, day } = pointerPreview(); + const handle = document.querySelector('[data-preview-boundary="end"]')!; + const origin = Number(handle.querySelector('.preview-handle-marker')!.getAttribute('x')) + 6; + field('preview-range-end').focus(); + send('pointerdown', handle, origin); + expect(document.activeElement).toBe(handle); + send('pointermove', svg, origin + day * 3); + expect(field('preview-range-end').value).toBe('2026-11-13'); + expect(point(1).getAttribute('aria-label')).toContain('2026-10-28'); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(field('preview-range-end').value).toBe('2026-11-13'); + send('pointerup', svg, origin + day * 3); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(field('preview-range-end').value).toBe('2026-11-14'); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(field('preview-range-end')); +}); + +test('rejects collisions/outside release, ignores other pointers and dirty input, and releases capture on close', () => { + const { svg, send, x, day } = pointerPreview(), origin = x(1); + send('pointerdown', svg, 100); expect(svg.setPointerCapture).not.toHaveBeenCalled(); + send('pointerdown', point(1), origin, 2, false); + send('pointermove', svg, origin + day, 2); + expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); + send('pointerdown', point(1), origin); + send('pointermove', svg, origin + day, 2); + send('pointermove', svg, origin + day * 7); + expect(field('preview-selected-date').value).toBe('2026-10-27'); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/order/); + send('pointerup', svg, 1000); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/plot/i); + field('preview-selected-date').value = ''; + field('preview-selected-date').dispatchEvent(new Event('input')); + send('pointerdown', point(1), origin); + expect(field('preview-selected-date').value).toBe(''); + field('preview-reset-dates').click(); + send('pointerdown', point(1), x(1)); + field('close-schedule-preview').click(); + expect(svg.releasePointerCapture).toHaveBeenCalledTimes(2); + openPlanSchedulePreview({ ...draft, preset: 'immediate' }, parent); + expect(document.querySelector('[data-preview-boundary]')).toBeNull(); +}); + +test('rechecks midnight on release and rolls back exactly with a reopen warning', () => { + const { svg, send, x, day } = pointerPreview(), origin = x(1); + send('pointerdown', point(1), origin); + send('pointermove', svg, origin + day); + jest.setSystemTime(new Date('2026-10-20T00:00:00Z')); + send('pointerup', svg, origin + day); + expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/reopen/i); + expect(field('preview-selected-date').disabled).toBe(false); +}); + + +test('reports capture failure and leaves dates and date-input access intact', () => { + const { svg, send, x } = pointerPreview(); + Object.assign(svg, { setPointerCapture: () => { throw new Error('Capture unavailable'); } }); + const hit = point(1).querySelector('.preview-touch-target')!; + expect(hit.namespaceURI).toBe('http://www.w3.org/1999/xhtml'); + expect(hit.tabIndex).toBe(-1); + send('pointerdown', hit, x(1)); + expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); + expect(field('preview-selected-date').disabled).toBe(false); + expect(document.getElementById('preview-date-error')?.textContent).toContain('Use date inputs'); +}); diff --git a/frontend/src/plan-schedule-preview.ts b/frontend/src/plan-schedule-preview.ts index fff24e8d..da814160 100644 --- a/frontend/src/plan-schedule-preview.ts +++ b/frontend/src/plan-schedule-preview.ts @@ -39,6 +39,8 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: const initialDates = points.map(point => point.date); let dates = [...initialDates], selected = 0; let dirty: 'step' | 'range' | null = null; + let gesture: { id: number; index: number; boundary: 'start' | 'end' | null; before: string[]; + startX: number; anchorX: number; day: number; domainStart: number; span: number; inverse: DOMMatrix } | null = null; const previous = document.getElementById('plan-schedule-preview'); if (previous) { closeModal(previous); previous.remove(); } const modal = document.createElement('div'); @@ -48,8 +50,13 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-labelledby', 'schedule-preview-title'); const width = Math.max(640, count * 48 + 96); - const x = (index: number): number => 48 + (draftDateDay(dates[index]!) - draftDateDay(dates[0]!) + 3) - / (draftDateDay(dates[count - 1]!) - draftDateDay(dates[0]!) + 8) * (width - 96); + const domain = (): [number, number] => [draftDateDay(dates[0]!) - 3, + draftDateDay(dates[count - 1]!) - draftDateDay(dates[0]!) + 8]; + const x = (index: number): number => { + const [startDay, span] = gesture ? [gesture.domainStart, gesture.span] : domain(); + return 48 + (draftDateDay(dates[index]!) - startDay) / span * (width - 96); + }; + const dateAt = (day: number): string => new Date(day * 86_400_000).toISOString().slice(0, 10); const y = (target: number): number => 260 - target * 2.2; let path = `M 48 ${y(0)}`; points.forEach((point, index) => { path += ` H ${x(index)} V ${y(point.target)}`; }); @@ -64,17 +71,25 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent:

Planned cumulative target (%)

Hypothetical dates, not observed utilization. All dates use UTC.

- + ${[0, 20, 40, 60, 80, 100].map(value => `${value}`).join('')} ${points.map((point, index) => ` - +
${index % Math.ceil(count / 5) === 0 || index === count - 1 ? `${point.date.slice(5)}` : ''}
`).join('')} + ${count > 1 ? ` + ${(['start', 'end'] as const).map((boundary, i) => { + const left = x(i === 0 ? 0 : count - 1); + return ` +
+ + ${i === 0 ? 'Start' : 'End'}
`; + }).join('')}` : ''}
-

○ Draft date · Arrow keys select steps; Alt+Left/Right moves a date one day. Use the Step selector or date inputs when points overlap.

+

○ Draft date · Drag dates or interval handles horizontally inside the plot. Arrow keys select steps; Alt+Left/Right moves a date one day. Handle arrows move a day; Enter edits its date. Use the Step selector or date inputs when points overlap or dates are farther away.

Draft date changes (0)

    @@ -99,20 +114,23 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: `; document.body.appendChild(modal); const buttons = Array.from(modal.querySelectorAll('[data-preview-step]')); + const handles = Array.from(modal.querySelectorAll('[data-preview-boundary]')); + const svg = modal.querySelector('svg')!; const dateInput = modal.querySelector('#preview-selected-date')!; const rangeStart = modal.querySelector('#preview-range-start')!; const rangeEnd = modal.querySelector('#preview-range-end'); const stepSelect = modal.querySelector('#preview-step-select')!; const error = (message: string): void => { modal.querySelector('#preview-date-error')!.textContent = message; }; const lockControls = (): void => { - dateInput.disabled = dirty === 'range'; - rangeStart.disabled = dirty === 'step'; - if (rangeEnd) rangeEnd.disabled = dirty === 'step'; - stepSelect.disabled = dirty !== null; - buttons.forEach(button => button.setAttribute('aria-disabled', String(dirty !== null))); + dateInput.disabled = !!gesture || dirty === 'range'; + rangeStart.disabled = !!gesture || dirty === 'step'; + if (rangeEnd) rangeEnd.disabled = !!gesture || dirty === 'step'; + stepSelect.disabled = dirty !== null || !!gesture; + modal.querySelector('#preview-reset-dates')!.disabled = !!gesture; + [...buttons, ...handles].forEach(button => button.setAttribute('aria-disabled', String(dirty !== null || !!gesture))); }; const select = (index: number, focus: boolean): void => { - if (dirty) return; + if (dirty || (gesture && index !== selected)) return; selected = index; const point = points[index]!; buttons.forEach((button, i) => { button.setAttribute('aria-pressed', String(i === index)); button.setAttribute('tabindex', i === index ? '0' : '-1'); }); @@ -127,12 +145,21 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: buttons.forEach((button, index) => { const left = x(index), date = dates[index]!; staircase += ` H ${left} V ${y(points[index]!.target)}`; - button.querySelectorAll('circle').forEach(circle => circle.setAttribute('cx', String(left))); + button.querySelector('.preview-hit')!.setAttribute('x', String(left - 22)); + button.querySelector('.preview-marker')!.setAttribute('cx', String(left)); const tick = button.querySelector('text'); if (tick) { tick.setAttribute('x', String(left)); tick.textContent = date.slice(5); } button.setAttribute('aria-label', `Step ${index + 1}, ${date}, target ${points[index]!.target}%`); }); modal.querySelector('.preview-staircase')!.setAttribute('d', staircase); + handles.forEach(handle => { + const boundary = handle.dataset.previewBoundary!, index = boundary === 'start' ? 0 : count - 1, left = x(index); + handle.querySelector('.preview-handle-hit')!.setAttribute('x', String(left - 22)); + handle.querySelector('.preview-handle-marker')!.setAttribute('x', String(left - 6)); + handle.querySelector('text')!.setAttribute('x', String(left)); + handle.setAttribute('aria-label', `Interval ${boundary}, ${dates[index]}, UTC. Arrows move a day; Enter edits date.`); + }); + modal.querySelector('.preview-interval')?.setAttribute('d', `M ${x(0)} 336 H ${x(count - 1)}`); modal.querySelector('#preview-start')!.textContent = dates[0]!; modal.querySelector('#preview-end')!.textContent = dates[count - 1]!; if (rangeStart.value !== dates[0]) rangeStart.value = dates[0]!; @@ -154,7 +181,7 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: [dateInput, rangeStart, ...(rangeEnd ? [rangeEnd] : [])].forEach(control => { const group = control === dateInput ? 'step' : 'range'; const begin = (): boolean => { - if (dirty && dirty !== group) return false; + if (gesture || (dirty && dirty !== group)) return false; dirty = group; lockControls(); return true; }; control.addEventListener('input', () => { begin(); }); @@ -165,20 +192,20 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: }); }); stepSelect.addEventListener('change', () => select(Number(stepSelect.value), false)); - modal.querySelector('#preview-reset-dates')!.addEventListener('click', () => apply(() => { + modal.querySelector('#preview-reset-dates')!.addEventListener('click', () => { if (gesture) return; apply(() => { try { validateDraftDates(initialDates); } catch { throw new Error('Initial dates are no longer future dates. Close and reopen the preview for fresh dates.'); } return [...initialDates]; - })); + }); }); buttons.forEach((button, index) => { button.addEventListener('click', () => select(index, true)); button.addEventListener('keydown', event => { if (['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { event.preventDefault(); - if (dirty) return; + if (dirty || gesture) return; if (event.altKey && ['ArrowLeft', 'ArrowRight'].includes(event.key)) { - apply(() => dates.map((date, i) => i === index ? new Date((draftDateDay(date) - + (event.key === 'ArrowRight' ? 1 : -1)) * 86_400_000).toISOString().slice(0, 10) : date)); + apply(() => dates.map((date, i) => i === index ? dateAt(draftDateDay(date) + + (event.key === 'ArrowRight' ? 1 : -1)) : date)); return; } const next = event.key === 'ArrowLeft' ? Math.max(0, index - 1) : event.key === 'ArrowRight' ? Math.min(count - 1, index + 1) : index; @@ -186,9 +213,85 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: } }); }); + const pointerX = (event: PointerEvent, inverse: DOMMatrix): number => { + const point = svg.createSVGPoint(); point.x = event.clientX; point.y = event.clientY; + const left = point.matrixTransform(inverse).x; + if (!Number.isFinite(left)) throw new Error('Drag coordinates unavailable. Use date inputs.'); + return left; + }; + const stopGesture = (cancel: boolean): void => { + const current = gesture; + if (!current) return; + gesture = null; + if (cancel) { + dates = [...current.before]; error(''); + try { validateDraftDates(dates); } + catch { error('Draft dates are no longer future dates. Close and reopen the preview.'); } + } + lockControls(); renderDates(); + if (svg.hasPointerCapture(current.id)) svg.releasePointerCapture(current.id); + }; + const movePointer = (event: PointerEvent): void => { + const current = gesture; + if (!current || current.id !== event.pointerId) return; + apply(() => { + const displacement = pointerX(event, current.inverse) - current.startX, left = current.anchorX + displacement; + if (left < 48 || left > width - 48) throw new Error('Stay inside the plot; use date inputs for farther dates.'); + const date = dateAt(current.day + Math.round(displacement / (width - 96) * current.span)); + return current.boundary ? redistributeDraftDates(current.before, + current.boundary === 'start' ? date : current.before[0]!, + current.boundary === 'end' ? date : current.before[count - 1]!) + : current.before.map((value, index) => index === current.index ? date : value); + }); + }; + svg.addEventListener('pointerdown', event => { + if (dirty || gesture || !event.isPrimary || event.button !== 0 || !(event.target instanceof Element)) return; + const target = event.target.closest('[data-preview-step], [data-preview-boundary]'); + if (!target) return; + const boundary = target.dataset.previewBoundary === 'start' ? 'start' : target.dataset.previewBoundary === 'end' ? 'end' : null; + const index = boundary === 'end' ? count - 1 : boundary === 'start' ? 0 : Number(target.dataset.previewStep); + if (!Number.isInteger(index) || index < 0 || index >= count) return; + if (!boundary) select(index, false); + target.focus({ preventScroll: true }); + try { + const matrix = svg.getScreenCTM(); + if (!matrix) throw new Error('Drag coordinates unavailable. Use date inputs.'); + const inverse = matrix.inverse(), [domainStart, span] = domain(); + gesture = { id: event.pointerId, index, boundary, before: [...dates], startX: pointerX(event, inverse), + anchorX: x(index), day: draftDateDay(dates[index]!), domainStart, span, inverse }; + svg.setPointerCapture(event.pointerId); lockControls(); event.preventDefault(); + } catch (reason) { + gesture = null; lockControls(); error(`Cannot start date drag. Use date inputs. ${reason instanceof Error ? reason.message : String(reason)}`); + } + }); + svg.addEventListener('pointermove', movePointer); + svg.addEventListener('pointerup', event => { + if (!gesture || gesture.id !== event.pointerId) return; + movePointer(event); + try { validateDraftDates(dates); stopGesture(false); } + catch { stopGesture(true); error('Draft dates are no longer future dates. Close and reopen the preview.'); } + }); + ['pointercancel', 'lostpointercapture'].forEach(type => svg.addEventListener(type, event => { + if (gesture?.id === (event as PointerEvent).pointerId) stopGesture(true); + })); + const cancelOnEscape = (event: KeyboardEvent): void => { + if (gesture && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); stopGesture(true); } + }; + modal.addEventListener('keydown', cancelOnEscape, true); + handles.forEach(handle => handle.addEventListener('keydown', event => { + if (!['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return; + event.preventDefault(); + if (dirty || gesture) return; + const startHandle = handle.dataset.previewBoundary === 'start', control = startHandle ? rangeStart : rangeEnd!; + if (event.key === 'Enter' || event.key === ' ') { control.focus(); return; } + const date = dateAt(draftDateDay(control.value) + (event.key === 'ArrowRight' ? 1 : -1)); + apply(() => redistributeDraftDates(dates, startHandle ? date : dates[0]!, startHandle ? dates[count - 1]! : date)); + })); modal.querySelector('#close-schedule-preview')!.addEventListener('click', () => closeModal(modal)); const wasInert = parent.inert; parent.inert = true; - select(0, false); - openModal(modal, { onClose: () => { parent.inert = wasInert; modal.remove(); } }); + renderDates(); + openModal(modal, { onClose: () => { + stopGesture(true); modal.removeEventListener('keydown', cancelOnEscape, true); parent.inert = wasInert; modal.remove(); + } }); } diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index b1b5ffcc..c1eca8c7 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -266,8 +266,16 @@ #preview-date-changes li { margin-bottom: 8px; overflow-wrap: anywhere; } .schedule-preview-layout details { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--cudly-border); } .schedule-preview-layout summary { cursor: pointer; margin-bottom: 12px; } + +.preview-touch-target { width: 100%; height: 100%; touch-action: none; cursor: ew-resize; } +.preview-handle-marker { fill: var(--cudly-primary); stroke: var(--cudly-surface); stroke-width: 2; pointer-events: none; } +[data-preview-boundary]:focus-visible .preview-handle-marker { stroke: var(--cudly-text); stroke-width: 4; } +.preview-interval { stroke: var(--cudly-border-strong); fill: none; pointer-events: none; } @media (max-width: 900px) { #plan-schedule-preview .schedule-preview-panel { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } .schedule-preview-layout { grid-template-columns: minmax(0, 1fr); } .schedule-preview-body { padding: 16px; } } + +#plan-schedule-preview [data-preview-step]:focus-visible, #plan-schedule-preview [data-preview-boundary]:focus-visible { outline: none; } +#plan-schedule-preview [data-preview-step]:focus-visible .preview-touch-target, #plan-schedule-preview [data-preview-boundary]:focus-visible .preview-touch-target { outline: 2px solid var(--cudly-primary); outline-offset: -2px; } diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index d36a537c..9ea65592 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -1,4 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Locator } from '@playwright/test'; + +test.use({ hasTouch: true }); import { mockApi, seedAuth } from './fixtures/recs'; for (const width of [1440, 1280, 390, 320]) { @@ -62,6 +64,80 @@ for (const width of [1440, 1280, 390, 320]) { const addDays = (date: string, days: number): string => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); const rangeStart = preview.locator('#preview-range-start'), rangeEnd = preview.locator('#preview-range-end'); const original = await rangeStart.inputValue(), later = addDays(await rangeEnd.inputValue(), 2); + + const beginDrag = async (target: Locator, offset = 0) => { + await target.scrollIntoViewIfNeeded(); + const box = (await target.boundingBox())!, svgBox = (await preview.locator('svg').boundingBox())!; + const svgWidth = await preview.locator('svg').evaluate(element => (element as SVGSVGElement).viewBox.baseVal.width); + const span = (Date.parse(await rangeEnd.inputValue()) - Date.parse(await rangeStart.inputValue())) / 86_400_000 + 8; + const origin = { x: box.x + box.width / 2 + offset, y: box.y + box.height / 2 }; + await preview.locator('svg').evaluate(element => element.addEventListener('pointerdown', event => element.setAttribute('data-test-pointer-id', String((event as PointerEvent).pointerId)), { once: true })); + await page.mouse.move(origin.x, origin.y); await page.mouse.down(); + return { ...origin, id: Number(await preview.locator('svg').getAttribute('data-test-pointer-id')), day: svgBox.width / svgWidth * (svgWidth - 96) / span, plotLeft: svgBox.x + 48 * svgBox.width / svgWidth }; + }; + const firstHit = preview.locator('[data-preview-step="0"] .preview-hit'); + let drag = await beginDrag(firstHit, 10); + await page.mouse.move(drag.x + drag.day / 4, drag.y); + await expect(rangeStart).toHaveValue(original); + await page.mouse.move(drag.x + drag.day, drag.y); await page.mouse.up(); + await expect(rangeStart).toHaveValue(addDays(original, 1)); + await expect(preview.locator('[data-preview-step="0"]')).toBeFocused(); + await preview.getByRole('button', { name: 'Reset draft dates' }).click(); + drag = await beginDrag(firstHit); + await page.mouse.move(drag.x + drag.day, drag.y); + await page.mouse.move(drag.x + drag.day * 7, drag.y); + await expect(preview.locator('#preview-date-error')).toContainText('order'); + await page.mouse.move(drag.plotLeft - 3, drag.y); await page.mouse.up(); + await expect(rangeStart).toHaveValue(addDays(original, 1)); + await expect(preview.locator('#preview-date-error')).toContainText('plot'); + await preview.getByRole('button', { name: 'Reset draft dates' }).click(); + const endHandle = preview.locator('[data-preview-boundary="end"]'); + await rangeEnd.focus(); + drag = await beginDrag(endHandle.locator('.preview-handle-hit')); + await expect(endHandle).toBeFocused(); + await expect(rangeEnd).toBeDisabled(); + await page.mouse.move(drag.x + drag.day * 3, drag.y); await page.mouse.up(); + await expect(rangeEnd).toHaveValue(addDays(later, 1)); + await endHandle.press('Enter'); await expect(rangeEnd).toBeFocused(); + const startHandle = preview.locator('[data-preview-boundary="start"]'); + drag = await beginDrag(startHandle.locator('.preview-handle-hit')); + await page.mouse.move(drag.x + drag.day, drag.y); await page.mouse.up(); + await expect(rangeStart).toHaveValue(addDays(original, 1)); + await expect(preview.locator('[data-preview-step="3"]')).toHaveAttribute('aria-label', /target 80%/); + await preview.getByRole('button', { name: 'Reset draft dates' }).click(); + drag = await beginDrag(firstHit); + await page.mouse.move(drag.x + drag.day, drag.y); + await preview.locator('svg').evaluate((element, id) => (element as SVGSVGElement).releasePointerCapture(id), drag.id); + await page.mouse.move(drag.x + drag.day + 1, drag.y); + await expect(rangeStart).toHaveValue(original); await page.mouse.up(); + await expect(preview.locator('#preview-date-changes summary')).toHaveText('Draft date changes (0)'); + drag = await beginDrag(firstHit); + await page.mouse.move(drag.x + drag.day, drag.y); + await page.keyboard.press('Escape'); await page.mouse.up(); + await expect(preview).toBeVisible(); + await expect(rangeStart).toHaveValue(original); + await page.keyboard.press('Escape'); await expect(preview).toHaveCount(0); + await expect(trigger).toBeFocused(); await trigger.click(); + if (width <= 390) { + const session = await page.context().newCDPSession(page); + try { + for (const [target, control] of [[firstHit, rangeStart], [endHandle.locator('.preview-handle-hit'), rangeEnd]] as const) { + await target.scrollIntoViewIfNeeded(); + const box = (await target.boundingBox())!, svgBox = (await preview.locator('svg').boundingBox())!; + const svgWidth = await preview.locator('svg').evaluate(element => (element as SVGSVGElement).viewBox.baseVal.width); + const span = (Date.parse(await rangeEnd.inputValue()) - Date.parse(await rangeStart.inputValue())) / 86_400_000 + 8; + const x = box.x + box.width / 2, y = box.y + box.height / 2, day = svgBox.width / svgWidth * (svgWidth - 96) / span; + const beforeDate = await control.inputValue(); + const labels = await preview.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label'))); + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); + await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x + day, y, id: 1 }] }); + await expect(control).toHaveValue(addDays(beforeDate, 1)); + await session.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }); + await expect(control).toHaveValue(beforeDate); + expect(await preview.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); + } + } finally { await session.detach(); } + } const endNode = await rangeEnd.elementHandle(); await rangeStart.fill(later); await rangeStart.dispatchEvent('change'); await expect(preview.locator('#preview-date-error')).not.toBeEmpty(); @@ -109,9 +185,27 @@ for (const width of [1440, 1280, 390, 320]) { await expect(dense.locator('[data-preview-step]')).toHaveCount(100); expect((await dense.locator('svg').boundingBox())!.width).toBeGreaterThanOrEqual(4896); expect(await dense.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + if (width <= 390) { + const scroll = dense.locator('.schedule-preview-scroll'); + await scroll.scrollIntoViewIfNeeded(); + const box = (await scroll.boundingBox())!, svgBox = (await dense.locator('svg').boundingBox())!; + const y = svgBox.y + svgBox.height * 304 / 370, left = box.x + 40, right = box.x + box.width - 40; + const hit = await page.evaluate(({ x, y }) => { const element = document.elementFromPoint(x, y); return { tag: element?.tagName, control: !!element?.closest("[data-preview-step],[data-preview-boundary]"), action: element ? getComputedStyle(element).touchAction : null }; }, { x: right, y }); + expect(hit.tag).toBe('svg'); expect(hit.control).toBe(false); expect(hit.action).toBe('auto'); + const beforeScroll = await scroll.evaluate(element => element.scrollLeft); + const labels = await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label'))); + const session = await page.context().newCDPSession(page); + try { + await session.send('Input.synthesizeScrollGesture', { x: right, y, xDistance: left - right, gestureSourceType: 'touch', speed: 600 }); + await expect.poll(() => scroll.evaluate(element => element.scrollLeft)).toBeGreaterThan(beforeScroll); + expect(await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); + } finally { await session.detach(); } + } await dense.locator('[data-preview-step="0"]').focus(); await page.keyboard.press('ArrowRight'); await expect(dense.locator('#preview-selected-step')).toHaveText('Step 2'); + await expect(dense.locator('[data-preview-step="1"] .preview-touch-target')).toHaveCSS('outline-width', '2px'); + await expect(dense.locator('[data-preview-step="1"] .preview-touch-target')).toHaveCSS('outline-style', 'solid'); await page.keyboard.press('Escape'); const parent = page.locator('#plan-modal'); await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); @@ -125,7 +219,7 @@ for (const width of [1440, 1280, 390, 320]) { await step.selectOption(String(index)); await overlap.locator('#preview-selected-date').fill(after(index)); await overlap.locator('#preview-selected-date').dispatchEvent('change'); } - const positions = await overlap.locator('.preview-hit').evaluateAll(elements => elements.map(element => Number(element.getAttribute('cx')))); + const positions = await overlap.locator('.preview-hit').evaluateAll(elements => elements.map(element => Number(element.getAttribute('x')) + 22)); expect(positions[1]! - positions[0]!).toBeLessThan(3); for (let index = 0; index < 4; index++) { await step.selectOption(String(index)); From 50ab58ab651654e583b385853864ccf22af7bba7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 15 Oct 2026 11:57:11 +0200 Subject: [PATCH 2/3] test(plans): emulate mobile viewport in touch coverage --- frontend/tests-e2e/plan-schedule-preview.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 9ea65592..6d988c65 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -3,8 +3,9 @@ import { test, expect, type Locator } from '@playwright/test'; test.use({ hasTouch: true }); import { mockApi, seedAuth } from './fixtures/recs'; -for (const width of [1440, 1280, 390, 320]) { - test(`local Create/Edit preview at ${width}px (mock API, no writes)`, async ({ page }, testInfo) => { +for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, () => { + test.use({ isMobile: width <= 390 }); + test(`local Create/Edit preview at ${width}px (mock API, no writes)`, async ({ page, isMobile }, testInfo) => { test.setTimeout(60_000); await page.setViewportSize({ width, height: 900 }); const errors: string[] = [], writes: string[] = []; @@ -239,6 +240,6 @@ for (const width of [1440, 1280, 390, 320]) { await page.keyboard.press('Escape'); expect(writes).toEqual([]); expect(errors).toEqual([]); - await testInfo.attach('production-preview-receipt', { body: JSON.stringify({ width, scripts, writes, errors }), contentType: 'application/json' }); + await testInfo.attach('production-preview-receipt', { body: JSON.stringify({ width, isMobile, scripts, writes, errors }), contentType: 'application/json' }); }); -} +}); From 145a868d5435b2bd29278e7c6323bafc59cc89ad Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 15 Oct 2026 14:37:53 +0200 Subject: [PATCH 3/3] test(plans): drive mobile pan through frame-separated touch --- frontend/tests-e2e/plan-schedule-preview.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 6d988c65..3b5670ec 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -197,7 +197,10 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, const labels = await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label'))); const session = await page.context().newCDPSession(page); try { - await session.send('Input.synthesizeScrollGesture', { x: right, y, xDistance: left - right, gestureSourceType: 'touch', speed: 600 }); + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: right, y, id: 1 }] }); + for (let move = 1; move <= 12; move++) { await page.evaluate(() => new Promise(requestAnimationFrame)); await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: right + (left - right) * move / 12, y, id: 1 }] }); } + await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); await expect.poll(() => scroll.evaluate(element => element.scrollLeft)).toBeGreaterThan(beforeScroll); expect(await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); } finally { await session.detach(); }