From cc73905e1ff8bf99260ac32c18ba982079ddc002 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 15 Oct 2026 00:56:18 +0200 Subject: [PATCH] fix(ui): honor plan notice period in purchase date defaults --- .../__tests__/plans-range-validation.test.ts | 72 ++++++++++++++++--- frontend/src/__tests__/plans.test.ts | 8 +++ frontend/src/plans.ts | 53 +++++++++----- .../tests-e2e/plan-purchase-notice.spec.ts | 64 +++++++++++++++++ 4 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 frontend/tests-e2e/plan-purchase-notice.spec.ts diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index 46b56a52..86eabc7d 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -16,6 +16,7 @@ import { openNewPlanModal, closePlanModal, openAddPurchasesModal } from '../plans'; import type { ToastOptions } from '../toast'; +import * as api from '../api'; jest.mock('../api', () => ({ getPlans: jest.fn(), @@ -458,7 +459,7 @@ describe('Add Purchases modal: inline range validation (#771)', () => { ppList.id = 'planned-purchases-list'; document.body.replaceChildren(plansList, ppList); - await openAddPurchasesModal('plan-xyz', 'My Test Plan'); + await openAddPurchasesModal('plan-xyz', 'My Test Plan', 3); }); function countInput(): HTMLInputElement { @@ -623,14 +624,11 @@ describe('Add Purchases modal: inline range validation (#771)', () => { /** * The start-date input must have its `min` attribute set to today's ISO date * so the browser date-picker blocks past dates while still allowing today. - * The default value stays tomorrow (to encourage scheduling ahead), but min - * is today so a same-day purchase remains possible. + * The suggested value allows the plan's notice period, but min stays today. */ describe('Add Purchases modal: start-date picker enforces future-only dates (QA 5.6)', () => { // Local-calendar ISO YYYY-MM-DD. Mirrors plans.ts toLocalDateInputValue. - // The production code must derive both `value` and `min` from local-time - // components (not toISOString, which is UTC) so a user west of UTC at - // 6pm local doesn't see "today" greyed out in the picker. + // The picker minimum stays local even though scheduled wire dates are UTC. function toLocalDateInputValue(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); @@ -646,7 +644,7 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA ppList.id = 'planned-purchases-list'; document.body.replaceChildren(plansList, ppList); - await openAddPurchasesModal('plan-xyz', 'My Test Plan'); + await openAddPurchasesModal('plan-xyz', 'My Test Plan', 3); }); it('sets min to today on the start-date input after the modal opens', () => { @@ -656,11 +654,10 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA expect(input.min).toBe(toLocalDateInputValue(new Date())); }); - it('keeps the default value set to tomorrow, not today', () => { + it('suggests a UTC date beyond the configured notice window', () => { const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - expect(input.value).toBe(toLocalDateInputValue(tomorrow)); + const now = new Date(); + expect(input.value).toBe(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 5)).toISOString().slice(0, 10)); }); it('uses the local calendar day, not UTC, so users west of UTC can still pick today', () => { @@ -676,3 +673,56 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA expect(input.min).toBe(localDay); }); }); + +describe('Add Purchases configured notice lead time', () => { + beforeEach(() => { + jest.clearAllMocks(); + document.body.replaceChildren(); + }); + afterEach(() => jest.useRealTimers()); + + it.each([ + [0, '2026-10-15T23:59:00Z', '2026-10-17'], + [1, '2026-12-31T23:59:00Z', '2027-01-03'], + [3, '2026-10-15T23:59:00Z', '2026-10-20'], + [30, '2026-01-31T23:59:00Z', '2026-03-04'], + [3, '2026-10-14T23:00:00-07:00', '2026-10-20'], + [3, '2026-10-15T00:01:00+14:00', '2026-10-19'], + [3, '2026-03-07T23:59:00-05:00', '2026-03-13'], + [3, '2026-10-24T23:59:00+02:00', '2026-10-29'], + ])('uses N=%s at %s to suggest %s', async (notice, now, expected) => { + jest.useFakeTimers({ now: new Date(now) }); + await openAddPurchasesModal('plan-notice', 'Notice plan', notice); + expect((document.getElementById('add-purchases-start-date') as HTMLInputElement).value).toBe(expected); + expect(document.getElementById('add-purchases-date-help')?.textContent).toContain(`${notice}-day notification window`); + expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(); + }); + + it.each([undefined, NaN, Infinity, -1, 31, 1.5])('fails visibly for invalid notice %s', async notice => { + await openAddPurchasesModal('plan-notice', 'Notice plan', notice as number); + expect(document.getElementById('add-purchases-error')?.textContent).toContain('missing or invalid'); + const count = document.getElementById('add-purchases-count') as HTMLInputElement; + count.dispatchEvent(new Event('input')); + expect(document.querySelector('button[type="submit"]')).toBeDisabled(); + document.getElementById('add-purchases-form')?.dispatchEvent(new Event('submit', { cancelable: true })); + expect(api.createPlannedPurchases).not.toHaveBeenCalled(); + }); + + it('preserves an earlier selected date and submits it while showing a warning', async () => { + jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }); + (api.createPlannedPurchases as jest.Mock).mockResolvedValue({}); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + await openAddPurchasesModal('plan-notice', 'Notice plan', 3); + const date = document.getElementById('add-purchases-start-date') as HTMLInputElement; + const minimum = date.min; + date.value = '2026-10-16'; + date.dispatchEvent(new Event('input')); + expect(date.value).toBe('2026-10-16'); + expect(date.min).toBe(minimum); + expect(document.getElementById('add-purchases-date-warning')?.textContent).toContain('may leave too little notice'); + expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(); + document.getElementById('add-purchases-form')?.dispatchEvent(new Event('submit', { cancelable: true })); + expect(api.createPlannedPurchases).toHaveBeenCalledWith('plan-notice', 1, '2026-10-16'); + await Promise.resolve(); + }); +}); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 8c6e766c..d70e2265 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -2420,6 +2420,7 @@ describe('Plans Module', () => { name: 'Test Plan', enabled: true, auto_purchase: false, + notification_days_before: 3, services: {} } ] @@ -2434,6 +2435,13 @@ describe('Plans Module', () => { await new Promise(resolve => setTimeout(resolve, 50)); }); + test('default date leaves the displayed plan notification window intact', () => { + const now = new Date(); + const expected = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 5)); + const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; + expect(input.value).toBe(expected.toISOString().slice(0, 10)); + }); + test('cancel button closes modal', async () => { const cancelBtn = document.getElementById('add-purchases-cancel') as HTMLButtonElement; cancelBtn?.click(); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index ebe2cd14..cf50e63d 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -1165,7 +1165,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan ` : ''}
- ${canAddPurchases && !isUnassigned ? `` : ''} + ${canAddPurchases && !isUnassigned ? `` : ''} ${canManagePlan && !isUnassigned ? `` : ''} ${canDeletePlan ? `` : ''} @@ -1240,7 +1240,7 @@ function renderPlans(plans: LocalPlan[]): void { toggle.addEventListener('change', () => void togglePlan(toggle.dataset['id'] || '', toggle.checked)); }); container.querySelectorAll('[data-action="add-purchases"]').forEach(btn => { - btn.addEventListener('click', () => void openAddPurchasesModal(btn.dataset['id'] || '', btn.dataset['name'] || '')); + btn.addEventListener('click', () => void openAddPurchasesModal(btn.dataset['id'] || '', btn.dataset['name'] || '', Number(btn.dataset['notificationDays']))); }); container.querySelectorAll('[data-action="edit-plan"]').forEach(btn => { btn.addEventListener('click', () => void editPlan(btn.dataset['id'] || '')); @@ -2206,7 +2206,7 @@ export function closePurchaseModal(): void { /** * Open modal to add planned purchases for a plan */ -export async function openAddPurchasesModal(planId: string, planName: string): Promise { +export async function openAddPurchasesModal(planId: string, planName: string, notificationDays: number): Promise { // Remove existing modal if present document.getElementById('add-purchases-modal')?.remove(); @@ -2227,9 +2227,10 @@ export async function openAddPurchasesModal(planId: string, planName: string): P

How many purchase steps to schedule (based on the plan's ramp schedule settings)

-

When to schedule the first purchase. Subsequent purchases follow the plan's interval.

+

+

@@ -2243,23 +2244,36 @@ export async function openAddPurchasesModal(planId: string, planName: string): P `; document.body.appendChild(modal); - // Set default start date to tomorrow. - // - // Date inputs use the user's *local* calendar day, so we must build the - // ISO string from local components (toISOString returns UTC). For a user - // west of UTC on the evening of day D, `new Date().toISOString()` returns - // D+1's calendar date, which would make `min` reject "today" in the - // picker and contradict the QA 5.6 promise that same-day purchases stay - // selectable. Mirrors isPlanOverdue's local-midnight pattern above. const startDateInput = document.getElementById('add-purchases-start-date') as HTMLInputElement; - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - startDateInput.value = toLocalDateInputValue(tomorrow); - startDateInput.min = toLocalDateInputValue(new Date()); + const now = new Date(); + const validNotice = Number.isInteger(notificationDays) && notificationDays >= 0 && notificationDays <= 30; + startDateInput.min = toLocalDateInputValue(now); + 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); + 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 => { + const selected = Date.parse(`${startDateInput.value}T00:00:00Z`); + document.getElementById('add-purchases-date-warning')!.textContent = selected < Date.now() + notificationDays * 24 * 60 * 60 * 1000 + ? `This date may leave too little notice for recommendations to attach. Suggested start date: ${suggested}.` + : ''; + }; + startDateInput.addEventListener('input', updateDateWarning); + updateDateWarning(); + } else { + const error = document.getElementById('add-purchases-error')!; + error.textContent = 'The plan notification window is missing or invalid. Reload the plan before scheduling purchases.'; + error.classList.remove('hidden'); + } // Add event listeners document.getElementById('add-purchases-cancel')?.addEventListener('click', closeAddPurchasesModal); - document.getElementById('add-purchases-form')?.addEventListener('submit', (e) => void handleAddPurchases(e)); + document.getElementById('add-purchases-form')?.addEventListener('submit', (e) => { + if (validNotice) void handleAddPurchases(e); + else e.preventDefault(); + }); // Inline range validation on the count field: surfaces a // "Must be a whole number between 1 and 52" message under the input @@ -2276,10 +2290,11 @@ export async function openAddPurchasesModal(planId: string, planName: string): P const submitBtn = modal.querySelector('button[type="submit"]'); if (countInput && submitBtn) { const syncSubmitBtn = (): void => { - submitBtn.disabled = countInput.getAttribute('aria-invalid') === 'true'; + submitBtn.disabled = !validNotice || countInput.getAttribute('aria-invalid') === 'true'; }; countInput.addEventListener('input', syncSubmitBtn); countInput.addEventListener('blur', syncSubmitBtn); + syncSubmitBtn(); } // Engage focus trap + Escape handler. The modal element itself is diff --git a/frontend/tests-e2e/plan-purchase-notice.spec.ts b/frontend/tests-e2e/plan-purchase-notice.spec.ts new file mode 100644 index 00000000..c3507c53 --- /dev/null +++ b/frontend/tests-e2e/plan-purchase-notice.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const cases = [ + { zone: 'UTC', now: '2026-10-15T23:59:00Z', suggested: '2026-10-20', selected: '2026-10-16' }, + { zone: 'America/Los_Angeles', now: '2026-03-08T07:59:00Z', suggested: '2026-03-13', selected: '2026-03-09' }, + { zone: 'Europe/Berlin', now: '2026-10-24T23:59:00Z', suggested: '2026-10-29', selected: '2026-10-26' }, +]; + +for (const scenario of cases) { + test.describe(scenario.zone, () => { + test.use({ timezoneId: scenario.zone }); + test('suggests notice lead time and preserves an earlier user date (mock API)', async ({ page }, testInfo) => { + const errors: 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()}`); }); + await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1' + ? route.fallback() : route.abort()); + await page.clock.setFixedTime(new Date(scenario.now)); + await seedAuth(page); + await mockApi(page); + const submitted: unknown[] = []; + await page.route('**/api/plans**', async route => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/purchases') && route.request().method() === 'POST') { + submitted.push(route.request().postDataJSON()); + await route.fulfill({ json: { created: 1 } }); + } else if (path.endsWith('/accounts')) { + await route.fulfill({ json: [] }); + } else { + await route.fulfill({ json: { plans: [{ id: 'plan-notice', name: 'Notice 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: { current_step: 0, total_steps: 4, interval_days: 7, percent_per_step: 25 }, + }] } }); + } + }); + await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [] } })); + 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); + await page.locator('[data-action="add-purchases"][data-id="plan-notice"]').click(); + const modal = page.locator('#add-purchases-modal'); + const date = page.locator('#add-purchases-start-date'); + await expect(date).toHaveValue(scenario.suggested); + await expect(page.locator('#add-purchases-date-help')).toContainText('3-day notification window'); + await expect(page.locator('#add-purchases-date-help')).toContainText('midnight UTC'); + const minimum = await date.getAttribute('min'); + const suggested = await date.inputValue(); + await date.fill(scenario.selected); + await expect(date).toHaveValue(scenario.selected); + await expect(date).toHaveAttribute('min', minimum!); + await expect(page.locator('#add-purchases-date-warning')).toContainText('may leave too little notice'); + await page.screenshot({ path: testInfo.outputPath('notice-modal.png'), fullPage: true }); + await modal.getByRole('button', { name: 'Add Purchases', exact: true }).click(); + await expect(modal).toHaveCount(0); + expect(submitted).toEqual([{ count: 1, start_date: scenario.selected }]); + expect(errors).toEqual([]); + await testInfo.attach('notice-date-receipt', { body: JSON.stringify({ ...scenario, suggested, minimum, notice: 3, submitted, scripts }), contentType: 'application/json' }); + }); + }); +}