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 ` : ''}
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