Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions frontend/src/__tests__/plan-draft-dates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { draftDateDay, redistributeDraftDates, validateDraftDates } from '../lib/plan-draft-dates';

beforeEach(() => jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }));
afterEach(() => jest.useRealTimers());

test.each(['', '2026-02-30', '2027-02-29', '2026-13-01', '2026-1-01', '0000-01-01', '10000-01-01', 'Infinity'])('rejects invalid UTC date %s', date => {
expect(() => draftDateDay(date)).toThrow(/valid UTC/);
});

test('uses calendar days through leap years, year rollover and DST boundaries', () => {
for (const [first, last] of [['2028-02-28', '2028-02-29'], ['2026-12-31', '2027-01-01'], ['2026-10-24', '2026-10-25']]) {
expect(draftDateDay(last!) - draftDateDay(first!)).toBe(1);
}
expect(Number.isFinite(draftDateDay('0001-01-01'))).toBe(true);
expect(Number.isFinite(draftDateDay('9999-12-31'))).toBe(true);
});

test('checks fresh UTC today and strict step order at every operation', () => {
expect(() => validateDraftDates(['2026-10-16', '2026-10-17'])).not.toThrow();
for (const dates of [['2026-10-15'], ['2026-10-20', '2026-10-20'], ['2026-10-21', '2026-10-20']]) {
expect(() => validateDraftDates(dates)).toThrow();
}
jest.setSystemTime(new Date('2026-10-16T00:00:00Z'));
expect(() => validateDraftDates(['2026-10-16'])).toThrow(/future/);
});

test('redistributes the same count with rounded dates, including one and one hundred steps', () => {
const dates = ['2026-10-20', '2026-10-27', '2026-11-03'];
expect(redistributeDraftDates(dates, '2026-10-20', '2026-10-25')).toEqual(['2026-10-20', '2026-10-23', '2026-10-25']);
expect(dates[1]).toBe('2026-10-27');
expect(redistributeDraftDates([dates[0]!], '2026-10-21', '2026-10-21')).toEqual(['2026-10-21']);
const dense = redistributeDraftDates(Array.from({ length: 100 }, () => dates[0]!), '2026-10-20', '2027-01-27');
expect(new Set(dense).size).toBe(100);
expect(dense[99]).toBe('2027-01-27');
expect(() => redistributeDraftDates(dense, '2026-10-20', '2027-01-26')).toThrow(/one calendar day/);
expect(() => redistributeDraftDates(dates, '2026-10-21', '2026-10-20')).toThrow();
});
96 changes: 95 additions & 1 deletion frontend/src/__tests__/plan-schedule-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test('shows truthful hypothetical dates, preserves parent values, and restores f
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);
expect(preview.querySelector<HTMLDetailsElement>('aside details')?.open).toBe(false);
preview.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.getElementById('plan-schedule-preview')).toBeNull();
expect(parent.inert).toBeFalsy();
Expand Down Expand Up @@ -61,3 +61,97 @@ test.each([
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');
});

const field = (id: string): HTMLInputElement => document.getElementById(id) as HTMLInputElement;
const changeDate = (id: string, value: string): void => {
const input = field(id);
input.focus(); input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
};
const point = (index: number): SVGGElement => document.querySelector(`[data-preview-step="${index}"]`)!;

test('edits actual draft dates, guards collisions and preserves nodes, shares and parent values', () => {
openPlanSchedulePreview(draft, parent);
const input = field('preview-selected-date'), first = point(0);
expect(input.readOnly).toBe(false);
changeDate(input.id, '2026-10-21');
expect(first.getAttribute('aria-label')).toContain('2026-10-21, target 20%');
expect(point(0)).toBe(first);
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true }));
expect(input.value).toBe('2026-10-22');
expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(4);
changeDate(input.id, '2026-10-27');
expect(first.getAttribute('aria-label')).toContain('2026-10-22');
expect(input.value).toBe('2026-10-27');
expect(field('preview-range-start').disabled).toBe(true);
point(2).dispatchEvent(new Event('click'));
expect(field(input.id)).toBe(input);
expect(document.activeElement).toBe(input);
expect(document.getElementById('preview-selected-step')?.textContent).toBe('Step 1');
expect(document.getElementById('preview-date-error')?.textContent).toMatch(/order/);
field('preview-reset-dates').click();
expect(input.value).toBe('2026-10-20');
expect(document.getElementById('preview-date-error')?.textContent).toBe('');
expect(parent.querySelector('input')?.value).toBe('unchanged');
expect(document.body.textContent).toContain('Save Plan does not apply these date edits');
});

test('retains partial paired inputs for correction and shows initial-to-draft differences', () => {
openPlanSchedulePreview(draft, parent);
const start = field('preview-range-start'), end = field('preview-range-end');
start.focus(); start.value = ''; start.dispatchEvent(new Event('input'));
point(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(start.value).toBe('');
expect(document.activeElement).toBe(start);
changeDate(start.id, '2026-11-20');
expect(start.value).toBe('2026-11-20');
expect(end.disabled).toBe(false);
expect(point(0).getAttribute('aria-label')).toContain('2026-10-20');
changeDate(end.id, '2026-12-10');
expect(document.activeElement).toBe(end);
expect(field(end.id)).toBe(end);
expect(point(2).getAttribute('aria-label')).toContain('2026-12-03');
expect(document.getElementById('preview-date-changes')?.textContent).toContain('2026-10-20 → 2026-11-20');
expect(field('preview-selected-date').disabled).toBe(false);
});

test('keeps single-step and legacy33 targets while exposing overlapping points through the Step selector', () => {
openPlanSchedulePreview({ ...draft, preset: 'immediate' }, parent);
expect(document.getElementById('preview-range-end')).toBeNull();
changeDate('preview-range-start', '2026-10-21');
expect(point(0).getAttribute('aria-label')).toContain('2026-10-21, target 80%');
openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33' }, parent);
changeDate('preview-range-end', '2026-10-22');
expect(point(2).getAttribute('aria-label')).toContain('2026-10-22, target 79.2%');
openPlanSchedulePreview({ ...draft, coverage: '0' }, parent);
changeDate('preview-range-end', '2027-10-20');
const selector = document.getElementById('preview-step-select') as HTMLSelectElement;
for (const [index, date] of [[1, '2026-10-21'], [2, '2026-10-22']] as const) {
selector.value = String(index); selector.dispatchEvent(new Event('change'));
changeDate('preview-selected-date', date);
}
const left = (index: number): number => Number(point(index).querySelector('circle')!.getAttribute('cx'));
expect(left(1) - left(0)).toBeLessThan(3);
for (let index = 0; index < 4; index++) {
selector.value = String(index); selector.dispatchEvent(new Event('change'));
expect(point(index).getAttribute('aria-pressed')).toBe('true');
expect(field('preview-selected-date').value).toBe(['2026-10-20', '2026-10-21', '2026-10-22', '2027-10-20'][index]);
}
});

test('rechecks operation-time UTC midnight and refuses stale reset without replacing repaired dates', () => {
openPlanSchedulePreview(draft, parent);
jest.setSystemTime(new Date('2026-10-20T00:00:00Z'));
changeDate('preview-selected-date', '2026-10-21');
expect(point(0).getAttribute('aria-label')).toContain('2026-10-21');
changeDate('preview-range-end', '2026-11-11');
field('preview-reset-dates').click();
expect(point(0).getAttribute('aria-label')).toContain('2026-10-21');
expect(document.getElementById('preview-date-error')?.textContent).toMatch(/reopen/i);
point(2).dispatchEvent(new Event('click'));
jest.setSystemTime(new Date('2026-10-21T00:00:00Z'));
point(2).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true }));
expect(point(2).getAttribute('aria-label')).toContain('2026-11-04');
expect(document.getElementById('preview-date-error')?.textContent).toMatch(/future/);
});
27 changes: 27 additions & 0 deletions frontend/src/lib/plan-draft-dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const DAY = 86_400_000;

export function draftDateDay(date: string): number {
const time = Date.parse(`${date}T00:00:00Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date.startsWith('0000') || !Number.isFinite(time)
|| new Date(time).toISOString().slice(0, 10) !== date) throw new Error('Choose a valid UTC calendar date.');
return time / DAY;
}

export function validateDraftDates(dates: readonly string[]): void {
const today = Math.floor(Date.now() / DAY);
let previous = -Infinity;
for (const date of dates) {
const day = draftDateDay(date);
if (day <= today) throw new Error('Draft dates must remain in the future (UTC).');
if (day <= previous) throw new Error('Keep dates in step order, at least one calendar day apart.');
previous = day;
}
}

export function redistributeDraftDates(dates: readonly string[], start: string, end: string): string[] {
const first = draftDateDay(start), last = dates.length === 1 ? first : draftDateDay(end);
if (last - first < dates.length - 1) throw new Error('Allow at least one calendar day between every draft step.');
const result = dates.map((_, index) => new Date((first + (dates.length === 1 ? 0
: Math.round(index * (last - first) / (dates.length - 1)))) * DAY).toISOString().slice(0, 10));
return result;
}
Loading
Loading