From b99cdf1db6028fa500e01689d423da98f066b056 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 03:50:04 +0200 Subject: [PATCH 1/5] refactor(plans): extract schedule intent from the preview dialog The inline panel imported buildLocalScheduleIntent from the dialog module, which is slated for removal. Move it and its input type to plan-schedule-intent.ts with their own tests so the dialog can go without breaking the panel. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plan-schedule-intent.test.ts | 37 +++++++++++++++++++ frontend/src/plan-editor-panel.ts | 4 +- frontend/src/plan-schedule-intent.ts | 34 +++++++++++++++++ frontend/src/plan-schedule-preview.ts | 35 +----------------- 4 files changed, 75 insertions(+), 35 deletions(-) create mode 100644 frontend/src/__tests__/plan-schedule-intent.test.ts create mode 100644 frontend/src/plan-schedule-intent.ts diff --git a/frontend/src/__tests__/plan-schedule-intent.test.ts b/frontend/src/__tests__/plan-schedule-intent.test.ts new file mode 100644 index 000000000..9cb7ede18 --- /dev/null +++ b/frontend/src/__tests__/plan-schedule-intent.test.ts @@ -0,0 +1,37 @@ +import { buildLocalScheduleIntent, PlanScheduleIntentInput } from '../plan-schedule-intent'; +import { suggestedPurchaseStartDate } from '../lib/calendar-date'; + +const draft: PlanScheduleIntentInput = { name: 'Compute ramp', preset: 'weekly-25pct', coverage: '80', stepPercent: '25', intervalDays: '7', notificationDays: '3', term: '1', payment: 'monthly' }; +beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }); }); +afterEach(() => { jest.useRealTimers(); }); + +test('weekly 25% builds four weekly steps from the suggested start', () => { + const intent = buildLocalScheduleIntent(draft); + expect(intent.dates).toEqual(['2026-10-20', '2026-10-27', '2026-11-03', '2026-11-10']); + expect(intent.shares).toEqual([25, 50, 75, 100]); + expect(intent.coverage).toBe(80); +}); + +test('preserves legacy custom 33% shares (3 steps, last share 99)', () => { + const intent = buildLocalScheduleIntent({ ...draft, preset: 'custom', stepPercent: '33' }); + expect(intent.dates).toHaveLength(3); + expect(intent.shares).toEqual([33, 66, 99]); + expect(intent.coverage * intent.shares[2]! / 100).toBeCloseTo(79.2); +}); + +test('preset wins over hidden custom fields', () => { + expect(buildLocalScheduleIntent({ ...draft, stepPercent: '1', intervalDays: '1' }).dates).toHaveLength(4); +}); + +test.each([ + { coverage: '' }, { coverage: '101' }, { coverage: '1.5' }, { notificationDays: '0' }, { notificationDays: 'Infinity' }, + { preset: 'toString' }, { preset: '' }, { preset: 'custom', stepPercent: '0' }, { preset: 'custom', intervalDays: '366' }, +])('rejects invalid input %j', change => { + expect(() => buildLocalScheduleIntent({ ...draft, ...change })).toThrow(); +}); + +test('uses UTC calendar dates across a year boundary', () => { + jest.setSystemTime(new Date('2026-12-31T23:59:00Z')); + expect(suggestedPurchaseStartDate(3)).toBe('2027-01-05'); + expect(buildLocalScheduleIntent(draft).dates[0]).toBe('2027-01-05'); +}); diff --git a/frontend/src/plan-editor-panel.ts b/frontend/src/plan-editor-panel.ts index 5f0cafe65..bd9f0baf8 100644 --- a/frontend/src/plan-editor-panel.ts +++ b/frontend/src/plan-editor-panel.ts @@ -1,9 +1,9 @@ import { wirePlanDateInteraction } from './plan-date-interaction'; -import { buildLocalScheduleIntent, PlanSchedulePreviewInput } from './plan-schedule-preview'; +import { buildLocalScheduleIntent, PlanScheduleIntentInput } from './plan-schedule-intent'; import { createEditorDraft, draftDateChanges, EditorDraft, redistributeEditorBlock, resetEditorDraft, selectDraftStep, setDraftDate } from './lib/plan-editor-draft'; import { renderSchedulePlot, schedulePlotFrame, updateSchedulePlot, SchedulePlotFrame } from './plan-schedule-plot'; -export function readPlanScheduleInput(form: HTMLFormElement): PlanSchedulePreviewInput { +export function readPlanScheduleInput(form: HTMLFormElement): PlanScheduleIntentInput { const value = (id: string): string => form.querySelector(`#${id}`)?.value ?? ''; return { name: value('plan-name'), preset: form.querySelector('input[name="ramp-schedule"]:checked')?.value ?? '', coverage: value('plan-coverage'), stepPercent: value('ramp-step-percent'), intervalDays: value('ramp-interval-days'), diff --git a/frontend/src/plan-schedule-intent.ts b/frontend/src/plan-schedule-intent.ts new file mode 100644 index 000000000..9a89d7871 --- /dev/null +++ b/frontend/src/plan-schedule-intent.ts @@ -0,0 +1,34 @@ +import { suggestedPurchaseStartDate } from './lib/calendar-date'; + +export interface PlanScheduleIntentInput { + name: string; + preset: string; + coverage: string; + stepPercent: string; + intervalDays: string; + notificationDays: string; + term: string; + payment: string; +} + +export function integer(value: string, label: string, min: number, max: number): number { + const parsed = value.trim() === '' ? NaN : Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new Error(`${label} must be a whole number between ${min} and ${max}`); + return parsed; +} + +export function buildLocalScheduleIntent(input: PlanScheduleIntentInput): { coverage: number; notificationDays: number; dates: string[]; shares: number[] } { + const coverage = integer(input.coverage, 'Target coverage', 0, 100); + const notice = integer(input.notificationDays, 'Notification days', 1, 30); + // Mirrors presets/shares in internal/config/types.go and custom + // step counting in PlanRequest.buildCustomRampSchedule, internal/api/types.go. + const presets: Record = { immediate: [100, 0], 'weekly-25pct': [25, 7], 'monthly-10pct': [10, 30] }; + const preset = Object.prototype.hasOwnProperty.call(presets, input.preset) ? presets[input.preset] : undefined; + if (!preset && input.preset !== 'custom') throw new Error('Choose a valid ramp schedule before previewing'); + const percent = preset?.[0] ?? integer(input.stepPercent, 'Step percent', 1, 100); + const interval = preset?.[1] ?? integer(input.intervalDays, 'Interval days', 1, 365); + const count = Math.floor(100 / percent); + const start = Date.parse(`${suggestedPurchaseStartDate(notice)}T00:00:00Z`); + const dates = Array.from({ length: count }, (_, index) => new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10)); + return { coverage, notificationDays: notice, dates, shares: dates.map((_, index) => Math.min(100, (index + 1) * percent)) }; +} diff --git a/frontend/src/plan-schedule-preview.ts b/frontend/src/plan-schedule-preview.ts index 19583594b..b66a473d6 100644 --- a/frontend/src/plan-schedule-preview.ts +++ b/frontend/src/plan-schedule-preview.ts @@ -2,41 +2,10 @@ import { closeModal, openModal } from './modal'; import { wirePlanDateInteraction } from './plan-date-interaction'; import { renderSchedulePlot, updateSchedulePlot, schedulePlotFrame, SchedulePlotFrame } from './plan-schedule-plot'; import { escapeHtml } from './utils'; -import { suggestedPurchaseStartDate } from './lib/calendar-date'; +import { buildLocalScheduleIntent, PlanScheduleIntentInput } from './plan-schedule-intent'; import { redistributeDraftDates, validateDraftDates } from './lib/plan-draft-dates'; -export interface PlanSchedulePreviewInput { - name: string; - preset: string; - coverage: string; - stepPercent: string; - intervalDays: string; - notificationDays: string; - term: string; - payment: string; -} - -function integer(value: string, label: string, min: number, max: number): number { - const parsed = value.trim() === '' ? NaN : Number(value); - if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new Error(`${label} must be a whole number between ${min} and ${max}`); - return parsed; -} - -export function buildLocalScheduleIntent(input: PlanSchedulePreviewInput): { coverage: number; notificationDays: number; dates: string[]; shares: number[] } { - const coverage = integer(input.coverage, 'Target coverage', 0, 100); - const notice = integer(input.notificationDays, 'Notification days', 1, 30); - // Mirrors presets/shares in internal/config/types.go and custom - // step counting in PlanRequest.buildCustomRampSchedule, internal/api/types.go. - const presets: Record = { immediate: [100, 0], 'weekly-25pct': [25, 7], 'monthly-10pct': [10, 30] }; - const preset = Object.prototype.hasOwnProperty.call(presets, input.preset) ? presets[input.preset] : undefined; - if (!preset && input.preset !== 'custom') throw new Error('Choose a valid ramp schedule before previewing'); - const percent = preset?.[0] ?? integer(input.stepPercent, 'Step percent', 1, 100); - const interval = preset?.[1] ?? integer(input.intervalDays, 'Interval days', 1, 365); - const count = Math.floor(100 / percent); - const start = Date.parse(`${suggestedPurchaseStartDate(notice)}T00:00:00Z`); - const dates = Array.from({ length: count }, (_, index) => new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10)); - return { coverage, notificationDays: notice, dates, shares: dates.map((_, index) => Math.min(100, (index + 1) * percent)) }; -} +export type PlanSchedulePreviewInput = PlanScheduleIntentInput; export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: HTMLElement): void { const { coverage, notificationDays: notice, dates: proposedDates, shares } = buildLocalScheduleIntent(input); From fd5208310b62fc52e82875c5fa96b251e5e6c892 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 03:51:57 +0200 Subject: [PATCH 2/5] fix(plans): make the inline schedule preview a live read-only panel The read-only panel kept stale plots on invalid input, ignored a ramp radio change after an invalid start, rebuilt on unrelated fields and showed five disabled controls plus a roadmap sentence. Rebuild only on the ramp radios and the four schedule inputs, clear the plot and show the message when the input is invalid, hide the unused controls, and announce one polite status (summary or validation message) updated on change while the plot follows input. The scroll area is a described region with a fixed minimum height so the layout does not jump. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/plan-editor-panel.test.ts | 55 +++++++++++++++++-- frontend/src/plan-editor-panel.ts | 36 ++++++++---- frontend/src/styles/plans.css | 2 + 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index 0cf4cae4c..d19a32946 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -89,16 +89,61 @@ test('actual Create pointer ownership guards submission, cancels on geometry cha }); describe('display-only default (Create dates are not saved yet)', () => { - beforeEach(() => { dispose(); dispose = mountPlanEditorPanel(host, form); }); - test('shows the graph and dates but disables every date control with a reason', () => { + const status = (): HTMLElement => host.querySelector('[data-editor="status"]')!; + const setting = (id: string, value: string, events: string[] = ['input', 'change']): void => { + const input = form.querySelector(`#${id}`)!; input.value = value; + for (const type of events) input.dispatchEvent(new Event(type, { bubbles: true })); + }; + const remount = (): void => { dispose(); dispose = mountPlanEditorPanel(host, form); }; + beforeEach(() => { + form.insertAdjacentHTML('beforeend', ''); remount(); + }); + test('shows the graph and dates read-only with the controls hidden', () => { expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(3); expect(host.querySelectorAll('svg [role=button]')).toHaveLength(0); expect(host.querySelector('.preview-hit, [data-preview-boundary]')).toBeNull(); - for (const key of ['step', 'date', 'start', 'end', 'reset']) expect(field(key).disabled).toBe(true); - expect(field('date').value).toBe('2026-10-20'); - expect(host.textContent).toContain('Custom step dates can be set once saving them is supported'); + const controls = host.querySelector('[data-editor="controls"]')!; + expect(controls.hidden).toBe(true); expect(controls.contains(field('date'))).toBe(true); + expect(host.textContent).not.toContain('Custom step dates can be set once saving them is supported'); expect(host.textContent).not.toContain('does not save date changes'); }); + test('invalid input clears the plot and shows the message; a valid value renders again', () => { + setting('plan-coverage', ''); + expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); + expect(status().textContent).toContain('Target coverage must be a whole number'); + setting('plan-coverage', '150'); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); + setting('plan-coverage', '70'); + expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(3); + expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 69.3%'); + expect(status().textContent).toBe('Target 70% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + }); + test('a ramp radio change rebuilds after an invalid start', () => { + form.querySelector('input[value="custom"]')!.checked = false; remount(); + expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); + const weekly = form.querySelector('input[value="weekly-25pct"]')!; weekly.checked = true; + weekly.dispatchEvent(new Event('change', { bubbles: true })); + expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(4); + }); + test('settings that do not affect the schedule never rebuild', () => { + const svg = host.querySelector('svg'), text = status().textContent; + setting('plan-term', '3'); setting('plan-payment', 'upfront'); setting('plan-name', 'Other'); + expect(host.querySelector('svg')).toBe(svg); expect(status().textContent).toBe(text); + setting('plan-coverage', ''); const message = status(), shown = message.textContent; + setting('plan-name', 'Another'); expect(status()).toBe(message); expect(message.textContent).toBe(shown); + }); + test('one polite live region carries the summary; the plot follows input, the status follows change', () => { + expect(status().getAttribute('role')).toBe('status'); expect(status().getAttribute('aria-live')).toBe('polite'); + expect(host.querySelectorAll('[role=status], [aria-live]')).toHaveLength(1); + expect(status().textContent).toBe('Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + expect(host.querySelector('[data-editor="error"]')?.getAttribute('role')).toBeNull(); + const region = host.querySelector('.schedule-preview-scroll')!; + expect(region.getAttribute('role')).toBe('region'); expect(region.getAttribute('aria-describedby')).toBe(status().id); + setting('plan-coverage', '60', ['input']); + expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 59.4%'); + expect(status().textContent).toContain('Target 80%'); + form.querySelector('#plan-coverage')!.dispatchEvent(new Event('change', { bubbles: true })); + expect(status().textContent).toContain('Target 60%'); + }); test('dragging a point is inert and typed input changes nothing', () => { const point = host.querySelector('[data-preview-step="0"]')!, label = point.getAttribute('aria-label'); const svg = host.querySelector('svg')!; diff --git a/frontend/src/plan-editor-panel.ts b/frontend/src/plan-editor-panel.ts index bd9f0baf8..52592cbab 100644 --- a/frontend/src/plan-editor-panel.ts +++ b/frontend/src/plan-editor-panel.ts @@ -15,25 +15,30 @@ export interface PlanEditorPanelOptions { editableDates?: boolean; } -const readOnlyReason = 'Custom step dates can be set once saving them is supported.'; +const readOnlyNotice = 'Hypothetical dates counted from today with the schedule settings. They are not saved or scheduled.'; +const scheduleSettingIds = ['plan-coverage', 'ramp-step-percent', 'ramp-interval-days', 'plan-notify-days']; export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, options: PlanEditorPanelOptions = {}): () => void { const editable = options.editableDates ?? false; host.hidden = false; host.closest('.plan-editor-layout')?.classList.add('has-editor-draft'); - host.innerHTML = `

Proposed purchase dates

${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyReason}

-
+ const statusId = `${host.id || 'plan-editor'}-status`; + host.innerHTML = `

Proposed purchase dates

${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyNotice}

+ ${editable ? '' : `

`} +

${editable ? `

Drag a point to change its date, or Start/End to space the dates.

Keyboard and touch help

Drag dates or Start/End handles. Arrow keys select; Alt+Left/Right moves a date. Enter on a handle opens its date input. Escape cancels a drag. Use Step/date inputs when points overlap.

` : ''}

Amount unavailable. Saved purchases and history are not shown.

+
-

Local date changes
    `; +

    Local date changes
      `; const field = (key: string): T => host.querySelector(`[data-editor="${key}"]`)!; + const status = editable ? null : field('status'); const step = field('step'), date = field('date'); const start = field('start'), end = field('end'); const rebuild = field('rebuild'), reset = field('reset'); @@ -105,13 +110,23 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o focusDate: boundary => (boundary === 'start' ? start : end).focus(), error: message => error(message || (rebuild.hidden ? '' : rebuildMessage)), }); - const build = (): void => { + const build = (announce = true): void => { if (gestureFrame) return; try { const intent = buildLocalScheduleIntent(readPlanScheduleInput(form)); state = createEditorDraft({ kind: 'create', totalSteps: intent.dates.length, shares: intent.shares, dates: intent.dates }); - coverage = intent.coverage; dirty = null; rebuild.hidden = true; error(''); paint(); - } catch (failure) { error((failure as Error).message); paint(); } + coverage = intent.coverage; dirty = null; rebuild.hidden = true; + if (editable) error(''); + paint(); + if (status && announce) { + const last = intent.dates[intent.dates.length - 1]!, count = intent.dates.length; + status.textContent = `Target ${coverage}% in ${count} ${count === 1 ? 'step' : 'steps'}, ${count === 1 ? intent.dates[0] : `${intent.dates[0]} to ${last}`} (UTC)`; + } + } catch (failure) { + if (editable) { error((failure as Error).message); paint(); return; } + disposeInteraction?.(); state = null; host.querySelector('.schedule-preview-scroll')!.replaceChildren(); + status!.textContent = (failure as Error).message; + } }; const commit = (): void => { if (!state || gestureFrame) return; @@ -138,11 +153,12 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o const settings = (event: Event): void => { if (gestureFrame) return; const target = event.target as HTMLInputElement; - if (!state) { if (target.id) build(); return; } + if (target.name !== 'ramp-schedule' && !scheduleSettingIds.includes(target.id)) return; + if (!editable || !state) { build(event.type === 'change'); return; } if (target.id === 'plan-coverage') { try { coverage = buildLocalScheduleIntent(readPlanScheduleInput(form)).coverage; if (!dirty && rebuild.hidden) error(''); paint(); } catch (failure) { error((failure as Error).message); } - } else if (target.name === 'ramp-schedule' || ['ramp-step-percent', 'ramp-interval-days', 'plan-notify-days'].includes(target.id)) { + } else { if (state && (dirty || draftDateChanges(state).length)) { rebuild.hidden = false; error(rebuildMessage); } else build(); } @@ -151,7 +167,7 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o if (editable && event.key === 'Enter' && [date, start, end].includes(event.target as HTMLInputElement)) { event.preventDefault(); commit(); } }; reset.onclick = () => { if (editable && state && !gestureFrame) { state = resetEditorDraft(state); dirty = null; error(rebuild.hidden ? '' : rebuildMessage); paint(); } }; - rebuild.onclick = build; + rebuild.onclick = () => build(); host.addEventListener('input', input); host.addEventListener('change', change); host.addEventListener('keydown', keydown); form.addEventListener('input', settings); form.addEventListener('change', settings); const submit = (event: Event): void => { diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 3351f7b98..a0a1cced9 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -295,4 +295,6 @@ #plan-editor-panel p { line-height: 1.5; margin: 12px 0; } #plan-editor-panel .editor-draft-notice { padding: 12px; background: var(--cudly-info-bg); border-radius: 8px; } #plan-editor-panel .editor-help { margin-bottom: 16px; } +#plan-editor-panel .editor-status { font-weight: 600; min-height: 1.5em; } +#plan-editor-panel .schedule-preview-scroll { min-height: 372px; } #plan-editor-panel [data-editor="changes"] { margin-top: 16px; border-top: 1px solid var(--cudly-border); } From 2c8fc969b328a4ead5cf0d62e3e4e626b3530b14 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 03:54:14 +0200 Subject: [PATCH 3/5] test(e2e): assert the read-only Create preview behaviour The Create modal no longer shows the roadmap sentence, disabled date controls or a local-change counter. Assert the hidden controls, the described scroll region and the live-region summary instead, so the spec follows the panel cleanup. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/tests-e2e/plan-schedule-preview.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index b4cd30e6b..df1d8a364 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -46,12 +46,14 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, if (mode === 'Create') { await page.locator('#plan-name').fill('Capacity ladder'); await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); - const panel = parent.locator('#plan-editor-panel'), date = panel.locator('[data-editor="date"]'); - await expect(panel).toBeVisible(); await expect(panel).toContainText('Custom step dates can be set once saving them is supported'); - await expect(panel).not.toContainText('does not save date changes'); + const panel = parent.locator('#plan-editor-panel'), live = panel.locator('[role="status"]'); + await expect(panel).toBeVisible(); await expect(panel).toContainText('They are not saved or scheduled'); + await expect(panel).not.toContainText('Custom step dates can be set once saving them is supported'); + await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); await expect(panel.locator('[data-preview-step]')).toHaveCount(4); await expect(panel.locator('svg [role="button"]')).toHaveCount(0); - for (const key of ['step', 'date', 'start', 'end', 'reset']) await expect(panel.locator(`[data-editor="${key}"]`)).toBeDisabled(); + await expect(panel.locator('[data-editor="controls"]')).toBeHidden(); + await expect(panel.locator('.schedule-preview-scroll')).toHaveAttribute('role', 'region'); await parent.locator('#plan-account-search').fill('AWS'); await parent.locator('.account-suggestion-item').first().click(); await expect(parent.locator('button[type="submit"]')).toBeEnabled(); await summary.click(); @@ -71,7 +73,7 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, await page.mouse.move(pointBox.x + pointBox.width / 2 + 60, pointBox.y + pointBox.height / 2); await page.mouse.up(); expect(await panel.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); expect(await parent.locator('#plan-form').evaluate(element => element.inert)).toBe(false); - await expect(panel.locator('[data-editor="changes"] summary')).toHaveText('Local date changes (0)'); + await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); expect(await parent.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); await parent.locator('.plan-editor-layout').evaluate(element => { element.scrollTop = 0; }); await page.screenshot({ path: testInfo.outputPath('create-inline-graph.png'), fullPage: true }); @@ -90,8 +92,8 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, await page.locator('#new-plan-btn').click(); await parent.locator('#plan-name').fill('Capacity ladder'); await expect(advanced).not.toHaveAttribute('open', ''); await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); - await expect(panel.locator('[data-editor="changes"] summary')).toHaveText('Local date changes (0)'); - await expect(panel.locator('[data-editor="date"]')).toBeDisabled(); + await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); + await expect(panel.locator('[data-editor="controls"]')).toBeHidden(); } else { await expect(parent.locator('#plan-editor-panel')).toBeHidden(); From 9ad53564904702e244305db0591cac85fe6de3a2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:00:52 +0200 Subject: [PATCH 4/5] fix(plans): split the preview summary from its live region The status node was both the visible summary and the live region and was only written on change, so while typing a recovered plot sat next to the old error text and a valid edit showed a stale target. Keep a visible summary updated on every build and a separate visually hidden polite status updated on change only. Hide the plot caption and region while the input is invalid. Rewrite the no-rebuild test so a rebuild would be observable, and drop a redundant e2e assertion. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/plan-editor-panel.test.ts | 46 ++++++++++++------- frontend/src/plan-editor-panel.ts | 18 +++++--- .../tests-e2e/plan-schedule-preview.spec.ts | 3 +- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index d19a32946..e8176fcda 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -107,15 +107,21 @@ describe('display-only default (Create dates are not saved yet)', () => { expect(host.textContent).not.toContain('Custom step dates can be set once saving them is supported'); expect(host.textContent).not.toContain('does not save date changes'); }); - test('invalid input clears the plot and shows the message; a valid value renders again', () => { - setting('plan-coverage', ''); + const summary = (): HTMLElement => host.querySelector('[data-editor="summary"]')!; + const caption = (): HTMLElement => host.querySelector('.editor-target')!; + const region = (): HTMLElement => host.querySelector('.schedule-preview-scroll')!; + test('invalid input clears the plot, caption and region and shows the message; input alone recovers', () => { + setting('plan-coverage', '', ['input']); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); - expect(status().textContent).toContain('Target coverage must be a whole number'); - setting('plan-coverage', '150'); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); - setting('plan-coverage', '70'); + expect(summary().textContent).toContain('Target coverage must be a whole number'); + expect(caption().hidden).toBe(true); expect(region().hidden).toBe(true); + expect(status().textContent).toBe('Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + setting('plan-coverage', '150', ['input']); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0); + setting('plan-coverage', '70', ['input']); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(3); expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 69.3%'); - expect(status().textContent).toBe('Target 70% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + expect(summary().textContent).toBe('Target 70% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + expect(caption().hidden).toBe(false); expect(region().hidden).toBe(false); }); test('a ramp radio change rebuilds after an invalid start', () => { form.querySelector('input[value="custom"]')!.checked = false; remount(); @@ -124,23 +130,29 @@ describe('display-only default (Create dates are not saved yet)', () => { weekly.dispatchEvent(new Event('change', { bubbles: true })); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(4); }); - test('settings that do not affect the schedule never rebuild', () => { - const svg = host.querySelector('svg'), text = status().textContent; - setting('plan-term', '3'); setting('plan-payment', 'upfront'); setting('plan-name', 'Other'); - expect(host.querySelector('svg')).toBe(svg); expect(status().textContent).toBe(text); - setting('plan-coverage', ''); const message = status(), shown = message.textContent; - setting('plan-name', 'Another'); expect(status()).toBe(message); expect(message.textContent).toBe(shown); + test('settings that do not affect the schedule never rebuild; schedule settings do', () => { + const svg = host.querySelector('svg'), text = summary().textContent; + form.querySelector('#plan-coverage')!.value = '60'; + for (const id of ['plan-term', 'plan-payment', 'plan-name']) { + setting(id, 'x', ['input', 'change']); + expect(host.querySelector('svg')).toBe(svg); expect(summary().textContent).toBe(text); + } + setting('plan-coverage', '60', ['input']); + expect(summary().textContent).toContain('Target 60%'); + form.querySelector('#plan-coverage')!.value = ''; + setting('plan-name', 'y'); expect(summary().textContent).toContain('Target 60%'); }); - test('one polite live region carries the summary; the plot follows input, the status follows change', () => { + test('visible summary follows every input; the hidden live region follows change only', () => { expect(status().getAttribute('role')).toBe('status'); expect(status().getAttribute('aria-live')).toBe('polite'); expect(host.querySelectorAll('[role=status], [aria-live]')).toHaveLength(1); - expect(status().textContent).toBe('Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'); + expect(summary().getAttribute('aria-live')).toBeNull(); expect(status().classList.contains('sr-only')).toBe(true); + const initial = 'Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)'; + expect(summary().textContent).toBe(initial); expect(status().textContent).toBe(initial); expect(host.querySelector('[data-editor="error"]')?.getAttribute('role')).toBeNull(); - const region = host.querySelector('.schedule-preview-scroll')!; - expect(region.getAttribute('role')).toBe('region'); expect(region.getAttribute('aria-describedby')).toBe(status().id); + expect(region().getAttribute('role')).toBe('region'); expect(region().getAttribute('aria-describedby')).toBe(summary().id); setting('plan-coverage', '60', ['input']); expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 59.4%'); - expect(status().textContent).toContain('Target 80%'); + expect(summary().textContent).toContain('Target 60%'); expect(status().textContent).toBe(initial); form.querySelector('#plan-coverage')!.dispatchEvent(new Event('change', { bubbles: true })); expect(status().textContent).toContain('Target 60%'); }); diff --git a/frontend/src/plan-editor-panel.ts b/frontend/src/plan-editor-panel.ts index 52592cbab..9b8b9d4b9 100644 --- a/frontend/src/plan-editor-panel.ts +++ b/frontend/src/plan-editor-panel.ts @@ -24,7 +24,7 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o host.closest('.plan-editor-layout')?.classList.add('has-editor-draft'); const statusId = `${host.id || 'plan-editor'}-status`; host.innerHTML = `

      Proposed purchase dates

      ${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyNotice}

      - ${editable ? '' : `

      `} + ${editable ? '' : `

      `}

      ${editable ? `

      Drag a point to change its date, or Start/End to space the dates.

      Keyboard and touch help @@ -38,7 +38,8 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o

      Local date changes
        `; const field = (key: string): T => host.querySelector(`[data-editor="${key}"]`)!; - const status = editable ? null : field('status'); + const status = editable ? null : field('status'), summary = editable ? null : field('summary'); + const plotHost = host.querySelector('.schedule-preview-scroll')!, caption = host.querySelector('.editor-target')!; const step = field('step'), date = field('date'); const start = field('start'), end = field('end'); const rebuild = field('rebuild'), reset = field('reset'); @@ -118,14 +119,17 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o coverage = intent.coverage; dirty = null; rebuild.hidden = true; if (editable) error(''); paint(); - if (status && announce) { - const last = intent.dates[intent.dates.length - 1]!, count = intent.dates.length; - status.textContent = `Target ${coverage}% in ${count} ${count === 1 ? 'step' : 'steps'}, ${count === 1 ? intent.dates[0] : `${intent.dates[0]} to ${last}`} (UTC)`; + if (status && summary) { + const count = intent.dates.length; + const text = `Target ${coverage}% in ${count} ${count === 1 ? 'step' : 'steps'}, ${count === 1 ? intent.dates[0] : `${intent.dates[0]} to ${intent.dates[count - 1]}`} (UTC)`; + summary.textContent = text; plotHost.hidden = caption.hidden = false; + if (announce) status.textContent = text; } } catch (failure) { if (editable) { error((failure as Error).message); paint(); return; } - disposeInteraction?.(); state = null; host.querySelector('.schedule-preview-scroll')!.replaceChildren(); - status!.textContent = (failure as Error).message; + disposeInteraction?.(); state = null; plotHost.replaceChildren(); plotHost.hidden = caption.hidden = true; + summary!.textContent = (failure as Error).message; + if (announce) status!.textContent = (failure as Error).message; } }; const commit = (): void => { diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index df1d8a364..2b6bb8acf 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -46,7 +46,7 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, if (mode === 'Create') { await page.locator('#plan-name').fill('Capacity ladder'); await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); - const panel = parent.locator('#plan-editor-panel'), live = panel.locator('[role="status"]'); + const panel = parent.locator('#plan-editor-panel'), live = panel.locator('[data-editor="summary"]'); await expect(panel).toBeVisible(); await expect(panel).toContainText('They are not saved or scheduled'); await expect(panel).not.toContainText('Custom step dates can be set once saving them is supported'); await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); @@ -73,7 +73,6 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, await page.mouse.move(pointBox.x + pointBox.width / 2 + 60, pointBox.y + pointBox.height / 2); await page.mouse.up(); expect(await panel.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); expect(await parent.locator('#plan-form').evaluate(element => element.inert)).toBe(false); - await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); expect(await parent.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); await parent.locator('.plan-editor-layout').evaluate(element => { element.scrollTop = 0; }); await page.screenshot({ path: testInfo.outputPath('create-inline-graph.png'), fullPage: true }); From 4383ae731737d74e00cb32f4b686fb5c3c1b3c13 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:05:57 +0200 Subject: [PATCH 5/5] fix(plans): hide the cleared preview region in the browser The scroll region sets display:flex, which overrides the hidden attribute, so an invalid input left an empty bordered 372px box visible. Add a [hidden] rule and an e2e check that clearing coverage hides the region and caption and a valid value restores them. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/plans.css | 1 + frontend/tests-e2e/plan-schedule-preview.spec.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index a0a1cced9..4120a735c 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -297,4 +297,5 @@ #plan-editor-panel .editor-help { margin-bottom: 16px; } #plan-editor-panel .editor-status { font-weight: 600; min-height: 1.5em; } #plan-editor-panel .schedule-preview-scroll { min-height: 372px; } +#plan-editor-panel .schedule-preview-scroll[hidden] { display: none; } #plan-editor-panel [data-editor="changes"] { margin-top: 16px; border-top: 1px solid var(--cudly-border); } diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 2b6bb8acf..8842da3dc 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -51,6 +51,12 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, await expect(panel).not.toContainText('Custom step dates can be set once saving them is supported'); await expect(live).toHaveText(/^Target \d+% in 4 steps, \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2} \(UTC\)$/); await expect(panel.locator('[data-preview-step]')).toHaveCount(4); + await parent.locator('#plan-coverage').fill(''); + await expect(live).toContainText('Target coverage must be a whole number'); + await expect(panel.locator('.schedule-preview-scroll')).toBeHidden(); await expect(panel.locator('.editor-target')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('create-invalid.png'), fullPage: true }); + await parent.locator('#plan-coverage').fill('80'); + await expect(panel.locator('.schedule-preview-scroll')).toBeVisible(); await expect(panel.locator('.editor-target')).toBeVisible(); await expect(panel.locator('svg [role="button"]')).toHaveCount(0); await expect(panel.locator('[data-editor="controls"]')).toBeHidden(); await expect(panel.locator('.schedule-preview-scroll')).toHaveAttribute('role', 'region');