From 21f8ab09321de434f9bef94cf660a805999d493e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 16:46:14 +0200 Subject: [PATCH 1/2] fix(plans): block past dates in Add Purchases start-date picker (closes #1249) Set `startDateInput.min` to today's ISO date after the default value (tomorrow) is assigned so the browser date-picker rejects past dates while still allowing today as a valid same-day start. Regression test added in plans-range-validation.test.ts: asserts `input.min === today ISO` after `openAddPurchasesModal` returns (FAIL pre-fix, PASS post-fix, 44 tests green). --- .../__tests__/plans-range-validation.test.ts | 39 +++++++++++++++++++ frontend/src/plans.ts | 1 + 2 files changed, 40 insertions(+) diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index 2eb66f9de..ca80cbacd 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -615,3 +615,42 @@ describe('Add Purchases modal: inline range validation (#771)', () => { expect(require('../api').createPlannedPurchases).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Add Purchases modal: start-date picker min attribute (QA 5.6) +// --------------------------------------------------------------------------- + +/** + * 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. + */ +describe('Add Purchases modal: start-date picker enforces future-only dates (QA 5.6)', () => { + beforeEach(async () => { + jest.clearAllMocks(); + const plansList = document.createElement('div'); + plansList.id = 'plans-list'; + const ppList = document.createElement('div'); + ppList.id = 'planned-purchases-list'; + document.body.replaceChildren(plansList, ppList); + + await openAddPurchasesModal('plan-xyz', 'My Test Plan'); + }); + + it('sets min to today on the start-date input after the modal opens', () => { + const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; + expect(input).not.toBeNull(); + + const todayIso = new Date().toISOString().split('T')[0]; + expect(input.min).toBe(todayIso); + }); + + it('keeps the default value set to tomorrow, not today', () => { + const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowIso = tomorrow.toISOString().split('T')[0]; + expect(input.value).toBe(tomorrowIso); + }); +}); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 930d5b5c8..2f88f516b 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -2098,6 +2098,7 @@ export async function openAddPurchasesModal(planId: string, planName: string): P tomorrow.setDate(tomorrow.getDate() + 1); const startDateInput = document.getElementById('add-purchases-start-date') as HTMLInputElement; startDateInput.value = tomorrow.toISOString().split('T')[0] ?? ''; + startDateInput.min = new Date().toISOString().split('T')[0] ?? ''; // Add event listeners document.getElementById('add-purchases-cancel')?.addEventListener('click', closeAddPurchasesModal); From d8e718e0d6e3fc8fa126be497eabfdc952abc3a1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 26 Jun 2026 15:53:26 +0200 Subject: [PATCH 2/2] fix(plans): set Add Purchases start-date min/value via local calendar day `new Date().toISOString().split('T')[0]` returns the UTC calendar date, not the user's local one. For any user west of UTC after their local-evening crossover, the prior `min = toISOString().split('T')[0]` would resolve to tomorrow's UTC date and grey out today in the picker, directly contradicting the PR's own claim that "today remains selectable (a same-day start is legitimate)" (QA 5.6). Extract a `toLocalDateInputValue(Date) -> YYYY-MM-DD` helper that builds the ISO string from local year/month/day components (mirrors the local-midnight pattern already used by `isPlanOverdue` above), and use it for both `value` (default tomorrow) and `min` (today) in `openAddPurchasesModal`. Regression test updated in `plans-range-validation.test.ts`: the prior assertion used the same UTC function as production code, so it would have gone green even with the bug present. The replacement asserts both `min` and `value` against the local-component derivation, and adds an explicit "local calendar day, not UTC" case that documents the failure shape. --- .../__tests__/plans-range-validation.test.ts | 30 ++++++++++++++++--- frontend/src/plans.ts | 27 ++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index ca80cbacd..46b56a52c 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -627,6 +627,17 @@ describe('Add Purchases modal: inline range validation (#771)', () => { * is today so a same-day purchase remains possible. */ 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. + function toLocalDateInputValue(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + beforeEach(async () => { jest.clearAllMocks(); const plansList = document.createElement('div'); @@ -642,15 +653,26 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; expect(input).not.toBeNull(); - const todayIso = new Date().toISOString().split('T')[0]; - expect(input.min).toBe(todayIso); + expect(input.min).toBe(toLocalDateInputValue(new Date())); }); it('keeps the default value set to tomorrow, not today', () => { const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowIso = tomorrow.toISOString().split('T')[0]; - expect(input.value).toBe(tomorrowIso); + expect(input.value).toBe(toLocalDateInputValue(tomorrow)); + }); + + it('uses the local calendar day, not UTC, so users west of UTC can still pick today', () => { + // Regression check for the UTC-vs-local bug: at 23:00 in (e.g.) Los + // Angeles on Dec 31, `new Date().toISOString().split('T')[0]` returns + // Jan 1 (UTC), which would make today.min = Jan 1 and reject Dec 31 — + // contradicting "today remains selectable". Assert min uses the same + // calendar day Date() reports via local getters (the values diverge + // only in non-UTC timezones, but the bug shape is the function call). + const input = document.getElementById('add-purchases-start-date') as HTMLInputElement; + const now = new Date(); + const localDay = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + expect(input.min).toBe(localDay); }); }); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 2f88f516b..7d8f3d18c 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -916,6 +916,18 @@ function extractPlanInfo(plan: BackendPlan): { provider: string | null; service: return { provider: null, service: '—', term: null, coverage: null }; } +// Format a Date as YYYY-MM-DD in the user's *local* calendar — the format +// expected by .value and .min. Built from local +// year/month/day components so we don't shift by the user's UTC offset +// (e.g. a US user at 6pm local already crosses into UTC's next day, so +// toISOString() would return the wrong calendar day for a date-picker). +function toLocalDateInputValue(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + // Returns true when the plan's next_execution_date is strictly before today. function isPlanOverdue(plan: BackendPlan): boolean { if (!plan.next_execution_date) return false; @@ -2093,12 +2105,19 @@ export async function openAddPurchasesModal(planId: string, planName: string): P `; document.body.appendChild(modal); - // Set default start date to tomorrow + // 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); - const startDateInput = document.getElementById('add-purchases-start-date') as HTMLInputElement; - startDateInput.value = tomorrow.toISOString().split('T')[0] ?? ''; - startDateInput.min = new Date().toISOString().split('T')[0] ?? ''; + startDateInput.value = toLocalDateInputValue(tomorrow); + startDateInput.min = toLocalDateInputValue(new Date()); // Add event listeners document.getElementById('add-purchases-cancel')?.addEventListener('click', closeAddPurchasesModal);