From ac72cebff0cd464d39b48186c207114336db8b09 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 15 Oct 2026 09:20:24 +0200 Subject: [PATCH] feat(plans): edit local draft schedule dates Keep purchase targets unchanged while adjusting preview dates with ordered UTC inputs, interval redistribution and keyboard movement. Show initial-to-draft differences and reject stale resets explicitly. The preview remains local and does not change Save Plan behavior. Refs #631 --- .../src/__tests__/plan-draft-dates.test.ts | 37 +++++++ .../__tests__/plan-schedule-preview.test.ts | 96 +++++++++++++++++- frontend/src/lib/plan-draft-dates.ts | 27 ++++++ frontend/src/plan-schedule-preview.ts | 97 +++++++++++++++++-- frontend/src/styles/plans.css | 9 +- .../tests-e2e/plan-schedule-preview.spec.ts | 70 ++++++++++++- 6 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 frontend/src/__tests__/plan-draft-dates.test.ts create mode 100644 frontend/src/lib/plan-draft-dates.ts diff --git a/frontend/src/__tests__/plan-draft-dates.test.ts b/frontend/src/__tests__/plan-draft-dates.test.ts new file mode 100644 index 00000000..d6bcb5bc --- /dev/null +++ b/frontend/src/__tests__/plan-draft-dates.test.ts @@ -0,0 +1,37 @@ +import { draftDateDay, redistributeDraftDates, validateDraftDates } from '../lib/plan-draft-dates'; + +beforeEach(() => jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') })); +afterEach(() => jest.useRealTimers()); + +test.each(['', '2026-02-30', '2027-02-29', '2026-13-01', '2026-1-01', '0000-01-01', '10000-01-01', 'Infinity'])('rejects invalid UTC date %s', date => { + expect(() => draftDateDay(date)).toThrow(/valid UTC/); +}); + +test('uses calendar days through leap years, year rollover and DST boundaries', () => { + for (const [first, last] of [['2028-02-28', '2028-02-29'], ['2026-12-31', '2027-01-01'], ['2026-10-24', '2026-10-25']]) { + expect(draftDateDay(last!) - draftDateDay(first!)).toBe(1); + } + expect(Number.isFinite(draftDateDay('0001-01-01'))).toBe(true); + expect(Number.isFinite(draftDateDay('9999-12-31'))).toBe(true); +}); + +test('checks fresh UTC today and strict step order at every operation', () => { + expect(() => validateDraftDates(['2026-10-16', '2026-10-17'])).not.toThrow(); + for (const dates of [['2026-10-15'], ['2026-10-20', '2026-10-20'], ['2026-10-21', '2026-10-20']]) { + expect(() => validateDraftDates(dates)).toThrow(); + } + jest.setSystemTime(new Date('2026-10-16T00:00:00Z')); + expect(() => validateDraftDates(['2026-10-16'])).toThrow(/future/); +}); + +test('redistributes the same count with rounded dates, including one and one hundred steps', () => { + const dates = ['2026-10-20', '2026-10-27', '2026-11-03']; + expect(redistributeDraftDates(dates, '2026-10-20', '2026-10-25')).toEqual(['2026-10-20', '2026-10-23', '2026-10-25']); + expect(dates[1]).toBe('2026-10-27'); + expect(redistributeDraftDates([dates[0]!], '2026-10-21', '2026-10-21')).toEqual(['2026-10-21']); + const dense = redistributeDraftDates(Array.from({ length: 100 }, () => dates[0]!), '2026-10-20', '2027-01-27'); + expect(new Set(dense).size).toBe(100); + expect(dense[99]).toBe('2027-01-27'); + expect(() => redistributeDraftDates(dense, '2026-10-20', '2027-01-26')).toThrow(/one calendar day/); + expect(() => redistributeDraftDates(dates, '2026-10-21', '2026-10-20')).toThrow(); +}); diff --git a/frontend/src/__tests__/plan-schedule-preview.test.ts b/frontend/src/__tests__/plan-schedule-preview.test.ts index 368ec6d2..6dc637c5 100644 --- a/frontend/src/__tests__/plan-schedule-preview.test.ts +++ b/frontend/src/__tests__/plan-schedule-preview.test.ts @@ -29,7 +29,7 @@ test('shows truthful hypothetical dates, preserves parent values, and restores f expect((document.getElementById('preview-selected-date') as HTMLInputElement).value).toBe('2026-11-10'); point.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(document.getElementById('preview-selected-step')?.textContent).toBe('Step 3'); - expect(preview.querySelector('details')?.open).toBe(false); + expect(preview.querySelector('aside details')?.open).toBe(false); preview.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(document.getElementById('plan-schedule-preview')).toBeNull(); expect(parent.inert).toBeFalsy(); @@ -61,3 +61,97 @@ test.each([ test('uses UTC calendar dates across a year boundary for both real callers', () => { expect(suggestedPurchaseStartDate(3, new Date('2026-12-31T23:59:00Z'))).toBe('2027-01-05'); }); + +const field = (id: string): HTMLInputElement => document.getElementById(id) as HTMLInputElement; +const changeDate = (id: string, value: string): void => { + const input = field(id); + input.focus(); input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +}; +const point = (index: number): SVGGElement => document.querySelector(`[data-preview-step="${index}"]`)!; + +test('edits actual draft dates, guards collisions and preserves nodes, shares and parent values', () => { + openPlanSchedulePreview(draft, parent); + const input = field('preview-selected-date'), first = point(0); + expect(input.readOnly).toBe(false); + changeDate(input.id, '2026-10-21'); + expect(first.getAttribute('aria-label')).toContain('2026-10-21, target 20%'); + expect(point(0)).toBe(first); + first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true })); + expect(input.value).toBe('2026-10-22'); + expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(4); + changeDate(input.id, '2026-10-27'); + expect(first.getAttribute('aria-label')).toContain('2026-10-22'); + expect(input.value).toBe('2026-10-27'); + expect(field('preview-range-start').disabled).toBe(true); + point(2).dispatchEvent(new Event('click')); + expect(field(input.id)).toBe(input); + expect(document.activeElement).toBe(input); + expect(document.getElementById('preview-selected-step')?.textContent).toBe('Step 1'); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/order/); + field('preview-reset-dates').click(); + expect(input.value).toBe('2026-10-20'); + expect(document.getElementById('preview-date-error')?.textContent).toBe(''); + expect(parent.querySelector('input')?.value).toBe('unchanged'); + expect(document.body.textContent).toContain('Save Plan does not apply these date edits'); +}); + +test('retains partial paired inputs for correction and shows initial-to-draft differences', () => { + openPlanSchedulePreview(draft, parent); + const start = field('preview-range-start'), end = field('preview-range-end'); + start.focus(); start.value = ''; start.dispatchEvent(new Event('input')); + point(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(start.value).toBe(''); + expect(document.activeElement).toBe(start); + changeDate(start.id, '2026-11-20'); + expect(start.value).toBe('2026-11-20'); + expect(end.disabled).toBe(false); + expect(point(0).getAttribute('aria-label')).toContain('2026-10-20'); + changeDate(end.id, '2026-12-10'); + expect(document.activeElement).toBe(end); + expect(field(end.id)).toBe(end); + expect(point(2).getAttribute('aria-label')).toContain('2026-12-03'); + expect(document.getElementById('preview-date-changes')?.textContent).toContain('2026-10-20 → 2026-11-20'); + expect(field('preview-selected-date').disabled).toBe(false); +}); + +test('keeps single-step and legacy33 targets while exposing overlapping points through the Step selector', () => { + openPlanSchedulePreview({ ...draft, preset: 'immediate' }, parent); + expect(document.getElementById('preview-range-end')).toBeNull(); + changeDate('preview-range-start', '2026-10-21'); + expect(point(0).getAttribute('aria-label')).toContain('2026-10-21, target 80%'); + openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33' }, parent); + changeDate('preview-range-end', '2026-10-22'); + expect(point(2).getAttribute('aria-label')).toContain('2026-10-22, target 79.2%'); + openPlanSchedulePreview({ ...draft, coverage: '0' }, parent); + changeDate('preview-range-end', '2027-10-20'); + const selector = document.getElementById('preview-step-select') as HTMLSelectElement; + for (const [index, date] of [[1, '2026-10-21'], [2, '2026-10-22']] as const) { + selector.value = String(index); selector.dispatchEvent(new Event('change')); + changeDate('preview-selected-date', date); + } + const left = (index: number): number => Number(point(index).querySelector('circle')!.getAttribute('cx')); + expect(left(1) - left(0)).toBeLessThan(3); + for (let index = 0; index < 4; index++) { + selector.value = String(index); selector.dispatchEvent(new Event('change')); + expect(point(index).getAttribute('aria-pressed')).toBe('true'); + expect(field('preview-selected-date').value).toBe(['2026-10-20', '2026-10-21', '2026-10-22', '2027-10-20'][index]); + } +}); + +test('rechecks operation-time UTC midnight and refuses stale reset without replacing repaired dates', () => { + openPlanSchedulePreview(draft, parent); + jest.setSystemTime(new Date('2026-10-20T00:00:00Z')); + changeDate('preview-selected-date', '2026-10-21'); + expect(point(0).getAttribute('aria-label')).toContain('2026-10-21'); + changeDate('preview-range-end', '2026-11-11'); + field('preview-reset-dates').click(); + expect(point(0).getAttribute('aria-label')).toContain('2026-10-21'); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/reopen/i); + point(2).dispatchEvent(new Event('click')); + jest.setSystemTime(new Date('2026-10-21T00:00:00Z')); + point(2).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true })); + expect(point(2).getAttribute('aria-label')).toContain('2026-11-04'); + expect(document.getElementById('preview-date-error')?.textContent).toMatch(/future/); +}); diff --git a/frontend/src/lib/plan-draft-dates.ts b/frontend/src/lib/plan-draft-dates.ts new file mode 100644 index 00000000..5be47a0a --- /dev/null +++ b/frontend/src/lib/plan-draft-dates.ts @@ -0,0 +1,27 @@ +const DAY = 86_400_000; + +export function draftDateDay(date: string): number { + const time = Date.parse(`${date}T00:00:00Z`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date.startsWith('0000') || !Number.isFinite(time) + || new Date(time).toISOString().slice(0, 10) !== date) throw new Error('Choose a valid UTC calendar date.'); + return time / DAY; +} + +export function validateDraftDates(dates: readonly string[]): void { + const today = Math.floor(Date.now() / DAY); + let previous = -Infinity; + for (const date of dates) { + const day = draftDateDay(date); + if (day <= today) throw new Error('Draft dates must remain in the future (UTC).'); + if (day <= previous) throw new Error('Keep dates in step order, at least one calendar day apart.'); + previous = day; + } +} + +export function redistributeDraftDates(dates: readonly string[], start: string, end: string): string[] { + const first = draftDateDay(start), last = dates.length === 1 ? first : draftDateDay(end); + if (last - first < dates.length - 1) throw new Error('Allow at least one calendar day between every draft step.'); + const result = dates.map((_, index) => new Date((first + (dates.length === 1 ? 0 + : Math.round(index * (last - first) / (dates.length - 1)))) * DAY).toISOString().slice(0, 10)); + return result; +} diff --git a/frontend/src/plan-schedule-preview.ts b/frontend/src/plan-schedule-preview.ts index 2df6ed87..fff24e8d 100644 --- a/frontend/src/plan-schedule-preview.ts +++ b/frontend/src/plan-schedule-preview.ts @@ -1,6 +1,7 @@ import { closeModal, openModal } from './modal'; import { escapeHtml } from './utils'; import { suggestedPurchaseStartDate } from './lib/calendar-date'; +import { draftDateDay, redistributeDraftDates, validateDraftDates } from './lib/plan-draft-dates'; export interface PlanSchedulePreviewInput { name: string; @@ -35,6 +36,9 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: date: new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10), target: coverage * Math.min(100, (index + 1) * percent) / 100, })); + const initialDates = points.map(point => point.date); + let dates = [...initialDates], selected = 0; + let dirty: 'step' | 'range' | null = null; const previous = document.getElementById('plan-schedule-preview'); if (previous) { closeModal(previous); previous.remove(); } const modal = document.createElement('div'); @@ -44,7 +48,8 @@ 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 => count === 1 ? width / 2 : 48 + index * (width - 96) / (count - 1); + const x = (index: number): number => 48 + (draftDateDay(dates[index]!) - draftDateDay(dates[0]!) + 3) + / (draftDateDay(dates[count - 1]!) - draftDateDay(dates[0]!) + 8) * (width - 96); 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)}`; }); @@ -52,7 +57,8 @@ export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: `; document.body.appendChild(modal); const buttons = Array.from(modal.querySelectorAll('[data-preview-step]')); + 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))); + }; const select = (index: number, focus: boolean): void => { + if (dirty) 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'); }); modal.querySelector('#preview-selected-step')!.textContent = `Step ${index + 1}`; - (modal.querySelector('#preview-selected-date') as HTMLInputElement).value = point.date; + if (dateInput.value !== dates[index]) dateInput.value = dates[index]!; + stepSelect.value = String(index); modal.querySelector('#preview-selected-target')!.textContent = `Planned target: ${point.target}%`; if (focus) buttons[index]!.focus(); }; + const renderDates = (): void => { + let staircase = `M 48 ${y(0)}`; + 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))); + 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); + modal.querySelector('#preview-start')!.textContent = dates[0]!; + modal.querySelector('#preview-end')!.textContent = dates[count - 1]!; + if (rangeStart.value !== dates[0]) rangeStart.value = dates[0]!; + if (rangeEnd && rangeEnd.value !== dates[count - 1]) rangeEnd.value = dates[count - 1]!; + const changes = dates.flatMap((date, index) => date === initialDates[index] ? [] : [`Step ${index + 1}: ${initialDates[index]} → ${date}`]); + modal.querySelector('#preview-date-changes summary')!.textContent = `Draft date changes (${changes.length})`; + const items = changes.map(change => { const item = document.createElement('li'); item.textContent = change; return item; }); + modal.querySelector('#preview-date-changes ul')!.replaceChildren(...items); + modal.querySelector('#preview-date-status')!.textContent = `Draft dates changed: ${changes.length}. Nothing is saved.`; + select(selected, false); + }; + const apply = (candidate: () => string[]): void => { + try { + const next = candidate(); + validateDraftDates(next); + dates = next; dirty = null; error(''); lockControls(); renderDates(); + } catch (reason) { error(reason instanceof Error ? reason.message : String(reason)); } + }; + [dateInput, rangeStart, ...(rangeEnd ? [rangeEnd] : [])].forEach(control => { + const group = control === dateInput ? 'step' : 'range'; + const begin = (): boolean => { + if (dirty && dirty !== group) return false; + dirty = group; lockControls(); return true; + }; + control.addEventListener('input', () => { begin(); }); + control.addEventListener('change', () => { + if (begin()) apply(() => control === dateInput + ? dates.map((date, index) => index === selected ? dateInput.value : date) + : redistributeDraftDates(dates, rangeStart.value, rangeEnd?.value ?? rangeStart.value)); + }); + }); + stepSelect.addEventListener('change', () => select(Number(stepSelect.value), false)); + modal.querySelector('#preview-reset-dates')!.addEventListener('click', () => 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 (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)); + return; + } const next = event.key === 'ArrowLeft' ? Math.max(0, index - 1) : event.key === 'ArrowRight' ? Math.min(count - 1, index + 1) : index; select(next, true); } diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 85273693..b1b5ffcc 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -248,8 +248,8 @@ .schedule-preview-notice { margin: 0 0 24px; padding: 12px; background: var(--cudly-info-bg); border-radius: 8px; } .schedule-preview-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } .schedule-preview-layout h3 { margin: 0 0 12px; font-size: 16px; } -.schedule-preview-scroll { overflow: auto; max-width: 100%; border: 1px solid var(--cudly-border); border-radius: 8px; } -.schedule-preview-scroll svg { display: block; max-width: none; height: 310px; } +.schedule-preview-scroll { display: flex; align-items: flex-start; overflow: auto; max-width: 100%; border: 1px solid var(--cudly-border); border-radius: 8px; } +.schedule-preview-scroll svg { display: block; flex: 1 0 auto; max-width: none; height: auto; } .schedule-preview-scroll text { fill: var(--cudly-text-muted); font: 12px system-ui; font-variant-numeric: tabular-nums; pointer-events: none; } .preview-grid { stroke: var(--cudly-border); fill: none; pointer-events: none; } .preview-staircase { stroke: var(--cudly-primary); stroke-width: 2; fill: none; pointer-events: none; } @@ -260,7 +260,10 @@ .schedule-preview-layout dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0 0 24px; } .schedule-preview-layout dt { color: var(--cudly-text-muted); } .schedule-preview-layout dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.schedule-preview-layout input[readonly] { display: block; width: 100%; padding: 8px; border: 1px solid var(--cudly-border); border-radius: 4px; } +.schedule-preview-layout input, .schedule-preview-layout select { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 12px; padding: 8px; border: 1px solid var(--cudly-border); border-radius: 4px; } +.schedule-preview-layout input:focus-visible, .schedule-preview-layout select:focus-visible { outline: 2px solid var(--cudly-primary); outline-offset: 2px; } +#preview-date-error { color: var(--cudly-error); overflow-wrap: anywhere; } +#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; } @media (max-width: 900px) { diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 64c08dfc..d36a537c 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -3,8 +3,8 @@ 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) => { + test.setTimeout(60_000); await page.setViewportSize({ width, height: 900 }); - await page.emulateMedia({ reducedMotion: 'reduce' }); const errors: string[] = [], writes: string[] = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); @@ -25,6 +25,7 @@ for (const width of [1440, 1280, 390, 320]) { const scripts = await page.locator('script[src]').evaluateAll(elements => elements.map(element => (element as HTMLScriptElement).src)); expect(scripts.some(source => /app\.[a-f0-9]+\.js/.test(source))).toBe(true); for (const mode of ['Create', 'Edit']) { + await page.emulateMedia({ reducedMotion: mode === 'Create' ? 'no-preference' : 'reduce' }); await page.locator(mode === 'Create' ? '#new-plan-btn' : '[data-action="edit-plan"][data-id="draft-plan"]').click(); const parent = page.locator('#plan-modal'); await expect(parent).toBeVisible(); @@ -40,10 +41,11 @@ for (const width of [1440, 1280, 390, 320]) { await expect(preview).toContainText('These dates are not saved or scheduled'); await expect(preview).toContainText('Saved purchases and history are not shown'); await expect(preview).toContainText('Amount unavailable'); + await expect(preview).toContainText('Save Plan does not apply these date edits'); expect(await preview.locator('header').evaluate(element => getComputedStyle(element).display)).toBe('block'); await expect(preview.locator('[data-preview-step]')).toHaveCount(4); expect(await parent.evaluate(element => element.inert)).toBe(true); - expect(await preview.locator('details').evaluate(element => element.open)).toBe(false); + expect(await preview.locator('aside details').evaluate(element => element.open)).toBe(false); await preview.getByRole('button', { name: 'Close preview' }).focus(); await page.keyboard.press('Tab'); await expect(preview.locator('.schedule-preview-scroll')).toBeFocused(); @@ -57,8 +59,41 @@ for (const width of [1440, 1280, 390, 320]) { await page.keyboard.press(' '); await page.keyboard.press('Enter'); await expect(preview.locator('#preview-selected-target')).toHaveText('Planned target: 80%'); + 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 endNode = await rangeEnd.elementHandle(); + await rangeStart.fill(later); await rangeStart.dispatchEvent('change'); + await expect(preview.locator('#preview-date-error')).not.toBeEmpty(); + await expect(rangeStart).toHaveValue(later); + await expect(preview.locator('#preview-step-select')).toBeDisabled(); + await rangeEnd.fill(addDays(later, 12)); await rangeEnd.dispatchEvent('change'); + await expect(rangeEnd).toBeFocused(); + expect(await endNode!.evaluate(element => element.isConnected)).toBe(true); + await expect(preview.locator('[data-preview-step="2"]')).toHaveAttribute('aria-label', new RegExp(addDays(later, 8))); + await preview.locator('#preview-step-select').selectOption('1'); + const second = preview.locator('[data-preview-step="1"]'), date = preview.locator('#preview-selected-date'); + await second.focus(); await second.press('Alt+ArrowRight'); + await expect(second).toBeFocused(); + await expect(date).toHaveValue(addDays(later, 5)); + const dateNode = await date.elementHandle(); + await date.fill(addDays(later, 8)); await date.dispatchEvent('change'); + await expect(date).toBeFocused(); + expect(await dateNode!.evaluate(element => element.isConnected)).toBe(true); + await expect(second).toHaveAttribute('aria-label', new RegExp(`${addDays(later, 5)}, target 40%`)); + await expect(preview.locator('#preview-date-error')).toContainText('order'); + await preview.getByRole('button', { name: 'Reset draft dates' }).click(); + await expect(rangeStart).toHaveValue(original); + await expect(preview.locator('#preview-date-changes summary')).toHaveText('Draft date changes (0)'); + await date.fill(addDays(original, 8)); await date.dispatchEvent('change'); + await preview.locator('#preview-date-changes summary').click(); + await expect(preview.locator('#preview-date-changes')).toContainText(`${addDays(original, 7)} → ${addDays(original, 8)}`); expect(await preview.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await preview.locator('.schedule-preview-body').evaluate(element => { element.scrollTop = 0; }); + await preview.locator('.schedule-preview-scroll').evaluate(element => { element.scrollLeft = 0; }); await page.screenshot({ path: testInfo.outputPath(`${mode.toLowerCase()}-preview.png`), fullPage: true }); + await date.scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath(`${mode.toLowerCase()}-controls.png`), fullPage: true }); await page.keyboard.press('Escape'); await expect(preview).toHaveCount(0); await expect(trigger).toBeFocused(); @@ -72,11 +107,42 @@ for (const width of [1440, 1280, 390, 320]) { await page.locator('#preview-plan-schedule-btn').click(); const dense = page.locator('#plan-schedule-preview'); 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); await dense.locator('[data-preview-step="0"]').focus(); await page.keyboard.press('ArrowRight'); await expect(dense.locator('#preview-selected-step')).toHaveText('Step 2'); await page.keyboard.press('Escape'); + const parent = page.locator('#plan-modal'); + await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); + await page.locator('#plan-coverage').fill('0'); + await page.locator('#preview-plan-schedule-btn').click(); + const overlap = page.locator('#plan-schedule-preview'), step = overlap.locator('#preview-step-select'); + const firstDate = await overlap.locator('#preview-range-start').inputValue(); + const after = (days: number): string => new Date(Date.parse(`${firstDate}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); + await overlap.locator('#preview-range-end').fill(after(365)); await overlap.locator('#preview-range-end').dispatchEvent('change'); + for (const index of [1, 2]) { + 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')))); + expect(positions[1]! - positions[0]!).toBeLessThan(3); + for (let index = 0; index < 4; index++) { + await step.selectOption(String(index)); + await expect(overlap.locator('#preview-selected-date')).toHaveValue(after(index === 3 ? 365 : index)); + } + await overlap.locator('[data-preview-step="0"]').focus(); + for (let index = 1; index < 4; index++) { + await page.keyboard.press('ArrowRight'); + await expect(overlap.locator('#preview-selected-step')).toHaveText(`Step ${index + 1}`); + } + await page.keyboard.press('Escape'); + await parent.locator('label.ramp-option:has(input[value="immediate"])').click(); + await page.locator('#preview-plan-schedule-btn').click(); + await expect(page.locator('#preview-range-end')).toHaveCount(0); + await page.locator('#preview-range-start').fill(after(3)); await page.locator('#preview-range-start').dispatchEvent('change'); + await expect(page.locator('#preview-selected-date')).toHaveValue(after(3)); + 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' });