diff --git a/frontend/src/__tests__/plan-schedule-preview.test.ts b/frontend/src/__tests__/plan-schedule-preview.test.ts new file mode 100644 index 00000000..368ec6d2 --- /dev/null +++ b/frontend/src/__tests__/plan-schedule-preview.test.ts @@ -0,0 +1,63 @@ +import { openPlanSchedulePreview, PlanSchedulePreviewInput } from '../plan-schedule-preview'; +import { closeModal } from '../modal'; +import { suggestedPurchaseStartDate } from '../lib/calendar-date'; + +const draft: PlanSchedulePreviewInput = { name: 'Compute ramp', preset: 'weekly-25pct', coverage: '80', stepPercent: '25', intervalDays: '7', notificationDays: '3', term: '1', payment: 'monthly' }; +let parent: HTMLElement; +beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }); + document.body.innerHTML = '
'; + parent = document.getElementById('plan-modal')!; + document.getElementById('trigger')!.focus(); +}); +afterEach(() => { + const preview = document.getElementById('plan-schedule-preview'); + if (preview) closeModal(preview); + jest.useRealTimers(); +}); + +test('shows truthful hypothetical dates, preserves parent values, and restores focus on Escape', () => { + openPlanSchedulePreview(draft, parent); + const preview = document.getElementById('plan-schedule-preview')!; + expect(preview.textContent).toContain('These dates are not saved or scheduled'); + expect(preview.textContent).toContain('Planned cumulative target (%)'); + expect(preview.textContent).toContain('Amount unavailable'); + expect(preview.querySelectorAll('[data-preview-step]')).toHaveLength(4); + expect(parent.inert).toBe(true); + const point = preview.querySelector('[data-preview-step="3"]') as unknown as HTMLElement; + point.dispatchEvent(new Event('click')); + 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); + preview.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.getElementById('plan-schedule-preview')).toBeNull(); + expect(parent.inert).toBeFalsy(); + expect(parent.querySelector('input')?.value).toBe('unchanged'); + expect(document.activeElement?.id).toBe('trigger'); +}); + +test('preserves legacy custom33 shares and safely renders text when reopened', () => { + openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33', name: '' }, parent); + expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3); + expect(document.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('79.2%'); + expect(document.querySelector('img')).toBeNull(); + openPlanSchedulePreview({ ...draft, coverage: '0', preset: 'immediate' }, parent); + expect(document.querySelectorAll('#plan-schedule-preview')).toHaveLength(1); + expect(document.querySelector('[data-preview-step]')?.getAttribute('aria-label')).toContain('target 0%'); + openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '1' }, parent); + expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(100); +}); + +test.each([ + { coverage: '' }, { coverage: '101' }, { coverage: '1.5' }, { notificationDays: '0' }, { notificationDays: 'Infinity' }, + { preset: 'toString' }, { preset: 'custom', stepPercent: '0' }, { preset: 'custom', intervalDays: '366' }, +])('rejects invalid draft %j without hiding the form', change => { + expect(() => openPlanSchedulePreview({ ...draft, ...change }, parent)).toThrow(); + expect(document.getElementById('plan-schedule-preview')).toBeNull(); + expect(parent.inert).toBeFalsy(); +}); + +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'); +}); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index d70e2265..c0b8f8e9 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -212,6 +212,20 @@ describe('Plans Module', () => { }); describe('loadPlans', () => { + test('wires actual form values to the no-write schedule preview', () => { + const button = document.createElement('button'); + button.id = 'preview-plan-schedule-btn'; + document.getElementById('plan-modal')!.appendChild(button); + (document.querySelector('input[value="custom"]') as HTMLInputElement).checked = true; + (document.getElementById('ramp-step-percent') as HTMLInputElement).value = '33'; + setupPlanHandlers(); + button.click(); + expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3); + expect(api.createPlan).not.toHaveBeenCalled(); + expect(api.updatePlan).not.toHaveBeenCalled(); + expect(api.createPlannedPurchases).not.toHaveBeenCalled(); + }); + test('fetches and renders plans', async () => { (api.getPlans as jest.Mock).mockResolvedValue({ plans: [ diff --git a/frontend/src/index.html b/frontend/src/index.html index 34acdfc0..702d1d96 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -968,6 +968,7 @@

Target Accounts < diff --git a/frontend/src/lib/calendar-date.ts b/frontend/src/lib/calendar-date.ts new file mode 100644 index 00000000..9757e9e7 --- /dev/null +++ b/frontend/src/lib/calendar-date.ts @@ -0,0 +1,3 @@ +export function suggestedPurchaseStartDate(noticeDays: number, now = new Date()): string { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + noticeDays + 2)).toISOString().slice(0, 10); +} diff --git a/frontend/src/plan-schedule-preview.ts b/frontend/src/plan-schedule-preview.ts new file mode 100644 index 00000000..2df6ed87 --- /dev/null +++ b/frontend/src/plan-schedule-preview.ts @@ -0,0 +1,111 @@ +import { closeModal, openModal } from './modal'; +import { escapeHtml } from './utils'; +import { suggestedPurchaseStartDate } from './lib/calendar-date'; + +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 openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: HTMLElement): void { + 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 points = Array.from({ length: count }, (_, index) => ({ + date: new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10), + target: coverage * Math.min(100, (index + 1) * percent) / 100, + })); + const previous = document.getElementById('plan-schedule-preview'); + if (previous) { closeModal(previous); previous.remove(); } + const modal = document.createElement('div'); + modal.id = 'plan-schedule-preview'; + modal.className = 'modal hidden'; + modal.setAttribute('role', 'dialog'); + 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 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)}`; }); + modal.innerHTML = ` + `; + document.body.appendChild(modal); + const buttons = Array.from(modal.querySelectorAll('[data-preview-step]')); + const select = (index: number, focus: boolean): void => { + 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; + modal.querySelector('#preview-selected-target')!.textContent = `Planned target: ${point.target}%`; + if (focus) buttons[index]!.focus(); + }; + buttons.forEach((button, index) => { + button.addEventListener('click', () => select(index, true)); + button.addEventListener('keydown', event => { + if (['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + event.preventDefault(); + const next = event.key === 'ArrowLeft' ? Math.max(0, index - 1) : event.key === 'ArrowRight' ? Math.min(count - 1, index + 1) : index; + select(next, true); + } + }); + }); + modal.querySelector('#close-schedule-preview')!.addEventListener('click', () => closeModal(modal)); + const wasInert = parent.inert; + parent.inert = true; + select(0, false); + openModal(modal, { onClose: () => { parent.inert = wasInert; modal.remove(); } }); +} diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index cf50e63d..1cf58245 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -16,6 +16,8 @@ import { openModal, closeModal } from './modal'; import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; +import { suggestedPurchaseStartDate } from './lib/calendar-date'; +import { openPlanSchedulePreview } from './plan-schedule-preview'; // pendingPlanRecommendations holds the resolved plan target captured at // "Plan from N selected" button-click time. The Plan flow used to re-derive @@ -2251,7 +2253,7 @@ export async function openAddPurchasesModal(planId: string, planName: string, no if (validNotice) { // The API parses date-only values at UTC midnight. The extra calendar // day avoids opening just before the notification/veto overlap ends. - const suggested = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + notificationDays + 2)).toISOString().slice(0, 10); + const suggested = suggestedPurchaseStartDate(notificationDays, now); startDateInput.value = suggested; document.getElementById('add-purchases-date-help')!.textContent = `This plan has a ${notificationDays}-day notification window. Dates use midnight UTC; subsequent purchases follow the plan's interval.`; const updateDateWarning = (): void => { @@ -2366,6 +2368,17 @@ let _unsubAccount: (() => void) | null = null; * the page level. */ export function setupPlanHandlers(): void { + const preview = document.getElementById('preview-plan-schedule-btn'); + if (preview) preview.onclick = () => { + const value = (id: string): string => (document.getElementById(id) as HTMLInputElement | HTMLSelectElement | null)?.value ?? ''; + const parent = document.getElementById('plan-modal'); + if (!parent) return; + try { + openPlanSchedulePreview({ name: value('plan-name'), preset: document.querySelector('input[name="ramp-schedule"]:checked')?.value ?? '', + coverage: value('plan-coverage'), stepPercent: value('ramp-step-percent'), intervalDays: value('ramp-interval-days'), + notificationDays: value('plan-notify-days'), term: value('plan-term'), payment: value('plan-payment') }, parent); + } catch (error) { showToast({ message: (error as Error).message, kind: 'error' }); } + }; // Drop any previous subscriptions before re-registering so we don't // accumulate duplicate loadPlans() calls on each invocation. _unsubProvider?.(); diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 120fdba1..85273693 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -232,3 +232,39 @@ background: var(--cudly-surface-muted); border-radius: 8px; } + +#plan-schedule-preview { z-index: 1010; } +#plan-schedule-preview .schedule-preview-panel { + width: min(1280px, calc(100vw - 48px)); max-width: none; + max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; + display: flex; flex-direction: column; +} +.schedule-preview-panel header, .schedule-preview-panel footer { padding: 20px 24px; flex: none; } +.schedule-preview-panel header { display: block; background: var(--cudly-surface); color: var(--cudly-text); border-bottom: 1px solid var(--cudly-border); } +.schedule-preview-panel header h2 { margin: 0; font-size: 22px; } +.schedule-preview-panel header p { margin: 6px 0 0; color: var(--cudly-text-muted); overflow-wrap: anywhere; } +.schedule-preview-panel footer { border-top: 1px solid var(--cudly-border); text-align: right; } +.schedule-preview-body { padding: 24px; overflow: auto; min-height: 0; } +.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 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; } +.preview-hit { fill: transparent; cursor: pointer; } +.preview-marker { fill: var(--cudly-surface); stroke: var(--cudly-primary); stroke-width: 2; pointer-events: none; } +[data-preview-step][aria-pressed="true"] .preview-marker { fill: var(--cudly-primary); stroke-width: 4; } +[data-preview-step]:focus-visible .preview-marker { stroke: var(--cudly-text); stroke-width: 5; } +.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 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) { + #plan-schedule-preview .schedule-preview-panel { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .schedule-preview-layout { grid-template-columns: minmax(0, 1fr); } + .schedule-preview-body { padding: 16px; } +} diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts new file mode 100644 index 00000000..64c08dfc --- /dev/null +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +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) => { + 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()); }); + page.on('requestfailed', request => errors.push(request.url())); + page.on('response', response => { if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); }); + page.on('request', request => { if (request.url().includes('/api/') && request.method() !== 'GET') writes.push(request.url()); }); + await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1' ? route.fallback() : route.abort()); + await seedAuth(page); + await mockApi(page); + const plan = { id: 'draft-plan', name: 'Existing plan', enabled: true, auto_purchase: false, notification_days_before: 3, + services: { ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'no-upfront', coverage: 80 } }, + ramp_schedule: { type: 'weekly', current_step: 1, total_steps: 4, step_interval_days: 7, percent_per_step: 25 } }; + await page.route('**/api/plans**', route => route.fulfill({ json: new URL(route.request().url()).pathname.endsWith('/accounts') ? [] + : new URL(route.request().url()).pathname.endsWith('/draft-plan') ? plan : { plans: [plan] } })); + await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [] } })); + await page.route('**/api/commitment-options**', route => route.fulfill({ json: { status: 'ok', aws: {} } })); + await page.goto('/plans'); + 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.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(); + if (mode === 'Create') { + await page.locator('#plan-name').fill('Capacity ladder'); + await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); + } + 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(); + const preview = page.locator('#plan-schedule-preview'); + await expect(preview).toBeVisible(); + 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'); + 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); + await preview.getByRole('button', { name: 'Close preview' }).focus(); + await page.keyboard.press('Tab'); + await expect(preview.locator('.schedule-preview-scroll')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(preview.getByRole('button', { name: 'Close preview' })).toBeFocused(); + const point = preview.locator('[data-preview-step="2"]'); + await point.locator('.preview-hit').click(); + await expect(preview.locator('#preview-selected-step')).toHaveText('Step 3'); + await point.press('ArrowRight'); + await expect(preview.locator('[data-preview-step="3"]')).toBeFocused(); + await page.keyboard.press(' '); + await page.keyboard.press('Enter'); + await expect(preview.locator('#preview-selected-target')).toHaveText('Planned target: 80%'); + expect(await preview.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`${mode.toLowerCase()}-preview.png`), fullPage: true }); + await page.keyboard.press('Escape'); + await expect(preview).toHaveCount(0); + await expect(trigger).toBeFocused(); + await expect(parent).toBeVisible(); + expect(await parent.locator('input, select, textarea').evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value))).toEqual(before); + await page.locator('#close-plan-modal-btn').click(); + } + await page.locator('#new-plan-btn').click(); + await page.locator('#plan-modal label.ramp-option').filter({ hasText: 'Define your own schedule' }).click(); + await page.locator('#ramp-step-percent').fill('1'); + 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.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'); + expect(writes).toEqual([]); + expect(errors).toEqual([]); + await testInfo.attach('production-preview-receipt', { body: JSON.stringify({ width, scripts, writes, errors }), contentType: 'application/json' }); + }); +}