diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index 0cf4cae4c..e8176fcda 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -89,16 +89,73 @@ 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'); }); + 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(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(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(); + 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; 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('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(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(); + 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(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%'); + }); 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/__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..9b8b9d4b9 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'), @@ -15,25 +15,31 @@ 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'), 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'); @@ -105,13 +111,26 @@ 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 && 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; plotHost.replaceChildren(); plotHost.hidden = caption.hidden = true; + summary!.textContent = (failure as Error).message; + if (announce) status!.textContent = (failure as Error).message; + } }; const commit = (): void => { if (!state || gestureFrame) return; @@ -138,11 +157,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 +171,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/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); diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 3351f7b98..4120a735c 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -295,4 +295,7 @@ #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 .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 b4cd30e6b..8842da3dc 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -46,12 +46,20 @@ 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('[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\)$/); 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); - 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 +79,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(panel.locator('[data-editor="changes"] summary')).toHaveText('Local date changes (0)'); 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 +97,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();