From 17035f4ffc7ad6cd7bda9aac88372bb1a65eaf59 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:28:31 +0200 Subject: [PATCH 1/5] feat(plans): mount the inline schedule preview for Edit Edit showed no preview once the separate preview dialog goes away, so openPlanForm now always mounts the panel. Tests cover weekly, custom and monthly plans (the panel is empty before the Edit opens), the weekly 20% prefill that maps to immediate (#1156), absent coverage, no save calls and a clean reopen. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plans.test.ts | 61 ++++++++++++++++++++++++++++ frontend/src/plans.ts | 10 ++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 42b6ff5c7..b8fdb4446 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -3434,4 +3434,65 @@ describe('Plans Module', () => { expect(badge?.getAttribute('title')).toContain(maliciousNote); }); }); + describe('inline schedule preview', () => { + const host = (): HTMLElement => document.getElementById('plan-editor-panel')!; + const points = (): number => host().querySelectorAll('[data-preview-step]').length; + const backendPlan = (ramp: Record, coverage: number | null = 80) => ({ + id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, notification_days_before: 3, + services: { ec2: { provider: 'aws', service: 'ec2', term: 1, payment: 'all-upfront', ...(coverage === null ? {} : { coverage }) } }, + ramp_schedule: ramp, + }); + const openEdit = async (plan: unknown): Promise => { + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [{ id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, services: {} }] }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (api.getPlan as jest.Mock).mockResolvedValue(plan); + await loadPlans(); + expect(points()).toBe(0); + (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); + await jest.advanceTimersByTimeAsync(50); + }; + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-10-15T12:00:00Z') }); + const form = document.getElementById('plan-form')!.innerHTML; + document.body.innerHTML = `
+ `; + _resetRampHandlersForTest(); + }); + afterEach(() => { closePlanModal(); jest.useRealTimers(); }); + + test.each([ + ['weekly 25%', { type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, 4], + ['custom 33% / 14 days', { type: 'custom', percent_per_step: 33, step_interval_days: 14 }, 3], + ['monthly 10%', { type: 'monthly', percent_per_step: 10, step_interval_days: 30 }, 10], + ])('Edit shows the %s schedule preview', async (_name, ramp, count) => { + await openEdit(backendPlan(ramp)); + expect(points()).toBe(count); + expect(host().hidden).toBe(false); + expect(api.createPlan).not.toHaveBeenCalled(); expect(api.updatePlan).not.toHaveBeenCalled(); + expect(api.createPlannedPurchases).not.toHaveBeenCalled(); + }); + + test('weekly 20% plan currently prefills as immediate and previews 1 step (#1156)', async () => { + // Documents current behaviour of the Edit prefill, which maps weekly != 25% to `immediate`; see #1156. + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 20, step_interval_days: 7 })); + expect(points()).toBe(1); + }); + + test('Edit with absent coverage shows the validation message and no points', async () => { + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, null)); + expect(points()).toBe(0); + expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); + }); + + test('reopening Create after Edit shows a fresh preview', async () => { + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); + closePlanModal(); + openCreatePlanModal(); + expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); + }); + + }); }); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 2fec43667..1ef2ae946 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -1256,7 +1256,7 @@ async function togglePlan(planId: string, enabled: boolean): Promise { } let disposePlanEditor: (() => void) | undefined; -function openPlanForm(modal: HTMLElement, create: boolean): void { +function openPlanForm(modal: HTMLElement): void { disposePlanEditor?.(); disposePlanEditor = undefined; const host = modal.querySelector('#plan-editor-panel'), form = modal.querySelector('#plan-form'); const advanced = modal.querySelector('#plan-advanced-settings'); @@ -1265,7 +1265,7 @@ function openPlanForm(modal: HTMLElement, create: boolean): void { if (advanced?.contains(event.target as Node)) advanced.open = true; }; form?.addEventListener('invalid', revealInvalid, true); - const disposePanel = create && host && form ? mountPlanEditorPanel(host, form) : undefined; + const disposePanel = host && form ? mountPlanEditorPanel(host, form) : undefined; disposePlanEditor = () => { try { disposePanel?.(); } finally { form?.removeEventListener('invalid', revealInvalid, true); } @@ -1364,7 +1364,7 @@ async function editPlan(planId: string): Promise { // Wire live range validation on all five numeric inputs (#702). wirePlanRangeInputs(); const planModal = document.getElementById('plan-modal'); - if (planModal) openPlanForm(planModal, false); + if (planModal) openPlanForm(planModal); return true; } catch (error) { console.error('Failed to load plan:', error); @@ -1823,7 +1823,7 @@ export function openCreatePlanModal(snapshot?: readonly api.Recommendation[]): v const planModal = document.getElementById('plan-modal'); if (planModal) { - openPlanForm(planModal, true); + openPlanForm(planModal); } } @@ -1855,7 +1855,7 @@ export function openNewPlanModal(): void { const planModal = document.getElementById('plan-modal'); if (planModal) { - openPlanForm(planModal, true); + openPlanForm(planModal); } } From d3966f923a2a47e7106969c84a00a6ab4b03ddfb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:29:19 +0200 Subject: [PATCH 2/5] feat(plans): say Edit previews restart from today The Edit preview is hypothetical and ignores executed purchases, so a plan already in progress would look like it starts over. A restartsFromToday panel option, passed only by the Edit call, swaps the notice to say so. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plans.test.ts | 12 ++++++++++-- frontend/src/plan-editor-panel.ts | 5 ++++- frontend/src/plans.ts | 6 +++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index b8fdb4446..3a7399f96 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -3435,6 +3435,7 @@ describe('Plans Module', () => { }); }); describe('inline schedule preview', () => { + const restartNotice = 'restarts from today for a plan already in progress; executed purchases are not shown'; const host = (): HTMLElement => document.getElementById('plan-editor-panel')!; const points = (): number => host().querySelectorAll('[data-preview-step]').length; const backendPlan = (ramp: Record, coverage: number | null = 80) => ({ @@ -3467,10 +3468,11 @@ describe('Plans Module', () => { ['weekly 25%', { type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, 4], ['custom 33% / 14 days', { type: 'custom', percent_per_step: 33, step_interval_days: 14 }, 3], ['monthly 10%', { type: 'monthly', percent_per_step: 10, step_interval_days: 30 }, 10], - ])('Edit shows the %s schedule preview', async (_name, ramp, count) => { + ])('Edit shows the %s schedule preview and the restart notice', async (_name, ramp, count) => { await openEdit(backendPlan(ramp)); expect(points()).toBe(count); expect(host().hidden).toBe(false); + expect(host().querySelector('.editor-draft-notice')!.textContent).toContain(restartNotice); expect(api.createPlan).not.toHaveBeenCalled(); expect(api.updatePlan).not.toHaveBeenCalled(); expect(api.createPlannedPurchases).not.toHaveBeenCalled(); }); @@ -3487,12 +3489,18 @@ describe('Plans Module', () => { expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); }); - test('reopening Create after Edit shows a fresh preview', async () => { + test('reopening Create after Edit shows the Create notice without leftover state', async () => { await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); closePlanModal(); openCreatePlanModal(); + expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today'); expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); }); + test('Create previews without the restart notice', () => { + openCreatePlanModal(); + expect(host().hidden).toBe(false); + expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today'); + }); }); }); diff --git a/frontend/src/plan-editor-panel.ts b/frontend/src/plan-editor-panel.ts index 9b8b9d4b9..2d78533db 100644 --- a/frontend/src/plan-editor-panel.ts +++ b/frontend/src/plan-editor-panel.ts @@ -13,9 +13,12 @@ export function readPlanScheduleInput(form: HTMLFormElement): PlanScheduleIntent export interface PlanEditorPanelOptions { // Create Save does not persist step dates yet, so the graph stays display-only until it does. editableDates?: boolean; + // Edit previews hypothetical dates from today, so say that executed purchases are not part of them. + restartsFromToday?: boolean; } const readOnlyNotice = 'Hypothetical dates counted from today with the schedule settings. They are not saved or scheduled.'; +const restartNotice = 'This preview restarts from today for a plan already in progress; executed purchases are not shown. Dates are hypothetical and 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 { @@ -23,7 +26,7 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o host.hidden = false; 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}

+ host.innerHTML = `

Proposed purchase dates

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

${editable ? '' : `

`}

${editable ? `

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

diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 1ef2ae946..c7d706519 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -1256,7 +1256,7 @@ async function togglePlan(planId: string, enabled: boolean): Promise { } let disposePlanEditor: (() => void) | undefined; -function openPlanForm(modal: HTMLElement): void { +function openPlanForm(modal: HTMLElement, restartsFromToday = false): void { disposePlanEditor?.(); disposePlanEditor = undefined; const host = modal.querySelector('#plan-editor-panel'), form = modal.querySelector('#plan-form'); const advanced = modal.querySelector('#plan-advanced-settings'); @@ -1265,7 +1265,7 @@ function openPlanForm(modal: HTMLElement): void { if (advanced?.contains(event.target as Node)) advanced.open = true; }; form?.addEventListener('invalid', revealInvalid, true); - const disposePanel = host && form ? mountPlanEditorPanel(host, form) : undefined; + const disposePanel = host && form ? mountPlanEditorPanel(host, form, { restartsFromToday }) : undefined; disposePlanEditor = () => { try { disposePanel?.(); } finally { form?.removeEventListener('invalid', revealInvalid, true); } @@ -1364,7 +1364,7 @@ async function editPlan(planId: string): Promise { // Wire live range validation on all five numeric inputs (#702). wirePlanRangeInputs(); const planModal = document.getElementById('plan-modal'); - if (planModal) openPlanForm(planModal); + if (planModal) openPlanForm(planModal, true); return true; } catch (error) { console.error('Failed to load plan:', error); From bacf0a2f70bb2b7eb4d148192100d26fc4034f52 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:38:40 +0200 Subject: [PATCH 3/5] feat(plans): put the form first and the preview beside it in the plan modal The form was the 280px column with the preview above it on phones, so the live effect was off-screen. The layout now uses grid areas without a DOM swap: form (360-440px) then a sticky preview on desktop, one column with the form first at 860px and below, and tighter modal padding at 480px so Cancel is no longer clipped at 320px. The e2e spec now asserts the Edit preview and notice plus column order, overflow and footer position at four widths. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/plans.css | 10 ++++++++-- .../tests-e2e/plan-schedule-preview.spec.ts | 20 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 4120a735c..ca97c05ce 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -284,11 +284,17 @@ #plan-modal .modal-content { width: min(1280px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; overflow: hidden; } #plan-form { min-width: 0; display: flex; flex-direction: column; min-height: 0; } .plan-editor-layout { overflow: auto; min-height: 0; } -.plan-editor-layout.has-editor-draft { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; } +.plan-editor-layout.has-editor-draft { display: grid; grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); grid-template-areas: "form panel"; grid-auto-rows: max-content; gap: 24px; align-items: start; } +.plan-editor-layout.has-editor-draft #plan-form { grid-area: form; } +#plan-editor-panel { grid-area: panel; position: sticky; top: 0; align-self: start; } .plan-editor-settings, #plan-editor-panel { min-width: 0; } #plan-modal .modal-buttons { flex-shrink: 0; } #plan-editor-panel [data-selected] .preview-marker { fill: var(--cudly-primary); stroke: var(--cudly-text); stroke-width: 3; } -@media (max-width: 800px) { .plan-editor-layout.has-editor-draft { display: block; } } +@media (max-width: 860px) { + .plan-editor-layout.has-editor-draft { grid-template-columns: minmax(0, 1fr); grid-template-areas: "form" "panel"; } + #plan-editor-panel { position: static; } +} +@media (max-width: 480px) { #plan-modal .modal-content { padding: 12px; } } #plan-advanced-settings { border-top: 1px solid var(--cudly-border); margin-top: 16px; padding-top: 16px; } #plan-advanced-settings > summary, #plan-editor-panel summary { cursor: pointer; padding: 8px 0; } diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 8842da3dc..becdf2e57 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -101,9 +101,25 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, await expect(panel.locator('[data-editor="controls"]')).toBeHidden(); } else { - await expect(parent.locator('#plan-editor-panel')).toBeHidden(); - await expect(parent.locator('#plan-editor-panel')).toBeEmpty(); + const panel = parent.locator('#plan-editor-panel'); + await expect(panel).toBeVisible(); + await expect(panel.locator('[data-preview-step]')).toHaveCount(4); + await expect(panel.locator('.editor-draft-notice')).toContainText('restarts from today for a plan already in progress; executed purchases are not shown'); + await expect(panel.locator('[data-editor="controls"]')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('edit-inline-graph.png'), fullPage: true }); } + const layoutForm = (await parent.locator('#plan-form').boundingBox())!, layoutPanel = (await parent.locator('#plan-editor-panel').boundingBox())!; + if (width > 860) { + expect(layoutForm.width).toBeGreaterThanOrEqual(360); expect(layoutPanel.x).toBeGreaterThanOrEqual(layoutForm.x + layoutForm.width); + expect(layoutPanel.width).toBeGreaterThan(layoutForm.width); + expect(await parent.locator('#plan-editor-panel').evaluate(element => getComputedStyle(element).position)).toBe('sticky'); + } else expect(layoutPanel.y).toBeGreaterThanOrEqual(layoutForm.y + layoutForm.height - 1); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + await parent.locator('#close-plan-modal-btn').scrollIntoViewIfNeeded(); + const cancel = (await parent.locator('#close-plan-modal-btn').boundingBox())!; + expect(cancel.x).toBeGreaterThanOrEqual(0); expect(cancel.x + cancel.width).toBeLessThanOrEqual(width); + const footerBox = (await parent.locator('.modal-buttons').boundingBox())!; + expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(900); const before = await parent.locator('input, select, textarea').evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value)); const trigger = page.locator('#preview-plan-schedule-btn'); await trigger.click(); From 4c4756f92db38447f6fa27092b1ba246190defd1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:44:23 +0200 Subject: [PATCH 4/5] test(plans): prove the Edit preview panel releases its listeners The reopen test passed even with the dispose calls removed. Two leak tests now spy on the form: closing Edit must leave no panel or modal listener and an unmounted panel, and Edit then Create without closing must leave exactly one set of listeners. Dropping the top-of-open dispose, the onClose dispose or the panel's removeEventListener calls each fail them. The weekly 20% test is marked to be inverted when #1156 is fixed. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plans.test.ts | 37 ++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 3a7399f96..963e2f302 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -3479,6 +3479,7 @@ describe('Plans Module', () => { test('weekly 20% plan currently prefills as immediate and previews 1 step (#1156)', async () => { // Documents current behaviour of the Edit prefill, which maps weekly != 25% to `immediate`; see #1156. + // Invert this expectation (weekly 20% should preview 5 steps) when #1156 is fixed. await openEdit(backendPlan({ type: 'weekly', percent_per_step: 20, step_interval_days: 7 })); expect(points()).toBe(1); }); @@ -3489,12 +3490,44 @@ describe('Plans Module', () => { expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); }); - test('reopening Create after Edit shows the Create notice without leftover state', async () => { + // Panel and modal listeners on the form (input/change bubble, invalid/submit capture), keyed by type:capture. + const trackFormListeners = () => { + const form = document.getElementById('plan-form')!; + const owned = new Set(['input:false', 'change:false', 'invalid:true', 'submit:true']); + const key = (type: string, o?: boolean | AddEventListenerOptions): string => `${type}:${o === true || (typeof o === 'object' && o.capture === true)}`; + const entries: { key: string; fn: unknown }[] = []; + const add = form.addEventListener.bind(form), remove = form.removeEventListener.bind(form); + jest.spyOn(form, 'addEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => { + if (owned.has(key(type, o))) entries.push({ key: key(type, o), fn }); + add(type, fn, o); + }); + jest.spyOn(form, 'removeEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => { + const index = entries.findIndex(entry => entry.key === key(type, o) && entry.fn === fn); + if (index >= 0) entries.splice(index, 1); + remove(type, fn, o); + }); + return entries; + }; + + test('closing the Edit modal removes every listener the panel and the modal added', async () => { + const entries = trackFormListeners(); await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); + expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']); closePlanModal(); + expect(entries).toEqual([]); + expect(host().hidden).toBe(true); expect(host().childElementCount).toBe(0); + expect(host().closest('.plan-editor-layout')!.classList.contains('has-editor-draft')).toBe(false); + }); + + test('Edit then Create without closing leaves exactly one panel listening and no Edit notice', async () => { + const entries = trackFormListeners(); + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); openCreatePlanModal(); + expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']); expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today'); - expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); + const coverage = document.getElementById('plan-coverage') as HTMLInputElement; + coverage.value = '60'; coverage.dispatchEvent(new Event('input', { bubbles: true })); + expect(host().querySelector('[data-editor="summary"]')!.textContent).toContain('Target 60%'); }); test('Create previews without the restart notice', () => { From 08706a20829bcfa8c3b4aa061cd6cc63aed64b86 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:51:28 +0200 Subject: [PATCH 5/5] fix(plans): cap the sticky preview, fix tab order and stack at 960px Review of the layout commit found three problems. The sticky preview had no height cap, so on a 768px window its lower part was clipped and unreachable; it now caps at the viewport minus the modal chrome and scrolls itself. The panel sat before the form in the DOM while showing after it, reversing tab and screen-reader order; it now follows the form. The preview was about 300px wide just above the 860px stack point, so the single column now starts at 960px. The e2e spec adds 1280x768 and 1024x900 and asserts the panel fits and its last line is reachable, the DOM and tab order, and uses the real viewport height for the footer check. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/index.html | 2 +- frontend/src/styles/plans.css | 6 ++-- .../tests-e2e/plan-schedule-preview.spec.ts | 32 ++++++++++++------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/index.html b/frontend/src/index.html index da2843aa7..b5ac42f2a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -820,7 +820,6 @@

User Management