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
67 changes: 62 additions & 5 deletions frontend/src/__tests__/plan-editor-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,73 @@ test('actual Create pointer ownership guards submission, cancels on geometry cha
});

describe('display-only default (Create dates are not saved yet)', () => {
beforeEach(() => { dispose(); dispose = mountPlanEditorPanel(host, form); });
test('shows the graph and dates but disables every date control with a reason', () => {
const status = (): HTMLElement => host.querySelector('[data-editor="status"]')!;
const setting = (id: string, value: string, events: string[] = ['input', 'change']): void => {
const input = form.querySelector<HTMLInputElement>(`#${id}`)!; input.value = value;
for (const type of events) input.dispatchEvent(new Event(type, { bubbles: true }));
};
const remount = (): void => { dispose(); dispose = mountPlanEditorPanel(host, form); };
beforeEach(() => {
form.insertAdjacentHTML('beforeend', '<input name="ramp-schedule" type="radio" value="weekly-25pct">'); remount();
});
test('shows the graph and dates read-only with the controls hidden', () => {
expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(3);
expect(host.querySelectorAll('svg [role=button]')).toHaveLength(0);
expect(host.querySelector('.preview-hit, [data-preview-boundary]')).toBeNull();
for (const key of ['step', 'date', 'start', 'end', 'reset']) expect(field(key).disabled).toBe(true);
expect(field('date').value).toBe('2026-10-20');
expect(host.textContent).toContain('Custom step dates can be set once saving them is supported');
const controls = host.querySelector<HTMLElement>('[data-editor="controls"]')!;
expect(controls.hidden).toBe(true); expect(controls.contains(field('date'))).toBe(true);
expect(host.textContent).not.toContain('Custom step dates can be set once saving them is supported');
expect(host.textContent).not.toContain('does not save date changes');
});
const summary = (): HTMLElement => host.querySelector('[data-editor="summary"]')!;
const caption = (): HTMLElement => host.querySelector('.editor-target')!;
const region = (): HTMLElement => host.querySelector('.schedule-preview-scroll')!;
test('invalid input clears the plot, caption and region and shows the message; input alone recovers', () => {
setting('plan-coverage', '', ['input']);
expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0);
expect(summary().textContent).toContain('Target coverage must be a whole number');
expect(caption().hidden).toBe(true); expect(region().hidden).toBe(true);
expect(status().textContent).toBe('Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)');
setting('plan-coverage', '150', ['input']); expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0);
setting('plan-coverage', '70', ['input']);
expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(3);
expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 69.3%');
expect(summary().textContent).toBe('Target 70% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)');
expect(caption().hidden).toBe(false); expect(region().hidden).toBe(false);
});
test('a ramp radio change rebuilds after an invalid start', () => {
form.querySelector<HTMLInputElement>('input[value="custom"]')!.checked = false; remount();
expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(0);
const weekly = form.querySelector<HTMLInputElement>('input[value="weekly-25pct"]')!; weekly.checked = true;
weekly.dispatchEvent(new Event('change', { bubbles: true }));
expect(host.querySelectorAll('[data-preview-step]')).toHaveLength(4);
});
test('settings that do not affect the schedule never rebuild; schedule settings do', () => {
const svg = host.querySelector('svg'), text = summary().textContent;
form.querySelector<HTMLInputElement>('#plan-coverage')!.value = '60';
for (const id of ['plan-term', 'plan-payment', 'plan-name']) {
setting(id, 'x', ['input', 'change']);
expect(host.querySelector('svg')).toBe(svg); expect(summary().textContent).toBe(text);
}
setting('plan-coverage', '60', ['input']);
expect(summary().textContent).toContain('Target 60%');
form.querySelector<HTMLInputElement>('#plan-coverage')!.value = '';
setting('plan-name', 'y'); expect(summary().textContent).toContain('Target 60%');
});
test('visible summary follows every input; the hidden live region follows change only', () => {
expect(status().getAttribute('role')).toBe('status'); expect(status().getAttribute('aria-live')).toBe('polite');
expect(host.querySelectorAll('[role=status], [aria-live]')).toHaveLength(1);
expect(summary().getAttribute('aria-live')).toBeNull(); expect(status().classList.contains('sr-only')).toBe(true);
const initial = 'Target 80% in 3 steps, 2026-10-20 to 2026-11-03 (UTC)';
expect(summary().textContent).toBe(initial); expect(status().textContent).toBe(initial);
expect(host.querySelector('[data-editor="error"]')?.getAttribute('role')).toBeNull();
expect(region().getAttribute('role')).toBe('region'); expect(region().getAttribute('aria-describedby')).toBe(summary().id);
setting('plan-coverage', '60', ['input']);
expect(host.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('target 59.4%');
expect(summary().textContent).toContain('Target 60%'); expect(status().textContent).toBe(initial);
form.querySelector('#plan-coverage')!.dispatchEvent(new Event('change', { bubbles: true }));
expect(status().textContent).toContain('Target 60%');
});
test('dragging a point is inert and typed input changes nothing', () => {
const point = host.querySelector('[data-preview-step="0"]')!, label = point.getAttribute('aria-label');
const svg = host.querySelector('svg')!;
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/__tests__/plan-schedule-intent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { buildLocalScheduleIntent, PlanScheduleIntentInput } from '../plan-schedule-intent';
import { suggestedPurchaseStartDate } from '../lib/calendar-date';

const draft: PlanScheduleIntentInput = { name: 'Compute ramp', preset: 'weekly-25pct', coverage: '80', stepPercent: '25', intervalDays: '7', notificationDays: '3', term: '1', payment: 'monthly' };
beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }); });
afterEach(() => { jest.useRealTimers(); });

test('weekly 25% builds four weekly steps from the suggested start', () => {
const intent = buildLocalScheduleIntent(draft);
expect(intent.dates).toEqual(['2026-10-20', '2026-10-27', '2026-11-03', '2026-11-10']);
expect(intent.shares).toEqual([25, 50, 75, 100]);
expect(intent.coverage).toBe(80);
});

test('preserves legacy custom 33% shares (3 steps, last share 99)', () => {
const intent = buildLocalScheduleIntent({ ...draft, preset: 'custom', stepPercent: '33' });
expect(intent.dates).toHaveLength(3);
expect(intent.shares).toEqual([33, 66, 99]);
expect(intent.coverage * intent.shares[2]! / 100).toBeCloseTo(79.2);
});

test('preset wins over hidden custom fields', () => {
expect(buildLocalScheduleIntent({ ...draft, stepPercent: '1', intervalDays: '1' }).dates).toHaveLength(4);
});

test.each([
{ coverage: '' }, { coverage: '101' }, { coverage: '1.5' }, { notificationDays: '0' }, { notificationDays: 'Infinity' },
{ preset: 'toString' }, { preset: '' }, { preset: 'custom', stepPercent: '0' }, { preset: 'custom', intervalDays: '366' },
])('rejects invalid input %j', change => {
expect(() => buildLocalScheduleIntent({ ...draft, ...change })).toThrow();
});

test('uses UTC calendar dates across a year boundary', () => {
jest.setSystemTime(new Date('2026-12-31T23:59:00Z'));
expect(suggestedPurchaseStartDate(3)).toBe('2027-01-05');
expect(buildLocalScheduleIntent(draft).dates[0]).toBe('2027-01-05');
});
44 changes: 32 additions & 12 deletions frontend/src/plan-editor-panel.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { wirePlanDateInteraction } from './plan-date-interaction';
import { buildLocalScheduleIntent, PlanSchedulePreviewInput } from './plan-schedule-preview';
import { buildLocalScheduleIntent, PlanScheduleIntentInput } from './plan-schedule-intent';
import { createEditorDraft, draftDateChanges, EditorDraft, redistributeEditorBlock, resetEditorDraft, selectDraftStep, setDraftDate } from './lib/plan-editor-draft';
import { renderSchedulePlot, schedulePlotFrame, updateSchedulePlot, SchedulePlotFrame } from './plan-schedule-plot';

export function readPlanScheduleInput(form: HTMLFormElement): PlanSchedulePreviewInput {
export function readPlanScheduleInput(form: HTMLFormElement): PlanScheduleIntentInput {
const value = (id: string): string => form.querySelector<HTMLInputElement | HTMLSelectElement>(`#${id}`)?.value ?? '';
return { name: value('plan-name'), preset: form.querySelector<HTMLInputElement>('input[name="ramp-schedule"]:checked')?.value ?? '',
coverage: value('plan-coverage'), stepPercent: value('ramp-step-percent'), intervalDays: value('ramp-interval-days'),
Expand All @@ -15,25 +15,31 @@ export interface PlanEditorPanelOptions {
editableDates?: boolean;
}

const readOnlyReason = 'Custom step dates can be set once saving them is supported.';
const readOnlyNotice = 'Hypothetical dates counted from today with the schedule settings. They are not saved or scheduled.';
const scheduleSettingIds = ['plan-coverage', 'ramp-step-percent', 'ramp-interval-days', 'plan-notify-days'];

export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, options: PlanEditorPanelOptions = {}): () => void {
const editable = options.editableDates ?? false;
host.hidden = false;
host.closest('.plan-editor-layout')?.classList.add('has-editor-draft');
host.innerHTML = `<h3>Proposed purchase dates</h3><p class="editor-draft-notice">${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyReason}</p>
<div class="schedule-preview-scroll" tabindex="0" aria-label="Proposed purchase calendar"></div>
const statusId = `${host.id || 'plan-editor'}-status`;
host.innerHTML = `<h3>Proposed purchase dates</h3><p class="editor-draft-notice">${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyNotice}</p>
${editable ? '' : `<p class="editor-status" data-editor="summary" id="${statusId}"></p><p class="sr-only" data-editor="status" role="status" aria-live="polite"></p>`}
<div class="schedule-preview-scroll" role="region" tabindex="0" aria-label="Proposed purchase calendar"${editable ? '' : ` aria-describedby="${statusId}"`}></div>
<p class="editor-target help-text"></p>${editable ? `<p class="help-text">Drag a point to change its date, or Start/End to space the dates.</p>
<details class="editor-help"><summary>Keyboard and touch help</summary>
<p class="help-text">Drag dates or Start/End handles. Arrow keys select; Alt+Left/Right moves a date. Enter on a handle opens its date input. Escape cancels a drag. Use Step/date inputs when points overlap.</p>
</details>` : ''}
<p class="help-text">Amount unavailable. Saved purchases and history are not shown.</p>
<div data-editor="controls"${editable ? '' : ' hidden'}>
<label>Step<select data-editor="step"></select></label><label>Date (UTC)<input data-editor="date" type="date"></label>
<div class="form-row"><label>Proposed start (UTC)<input data-editor="start" type="date"></label>
<label data-editor="end-label">Proposed end (UTC)<input data-editor="end" type="date"></label></div>
<button type="button" data-editor="reset">Reset draft dates</button><button type="button" data-editor="rebuild" hidden>Rebuild proposed dates</button>
<p data-editor="error" role="alert"></p><details data-editor="changes"><summary>Local date changes</summary><ul></ul></details>`;
<p data-editor="error"${editable ? ' role="alert"' : ''}></p><details data-editor="changes"><summary>Local date changes</summary><ul></ul></details></div>`;
const field = <T extends HTMLElement>(key: string): T => host.querySelector<T>(`[data-editor="${key}"]`)!;
const status = editable ? null : field('status'), summary = editable ? null : field('summary');
const plotHost = host.querySelector<HTMLElement>('.schedule-preview-scroll')!, caption = host.querySelector<HTMLElement>('.editor-target')!;
const step = field<HTMLSelectElement>('step'), date = field<HTMLInputElement>('date');
const start = field<HTMLInputElement>('start'), end = field<HTMLInputElement>('end');
const rebuild = field<HTMLButtonElement>('rebuild'), reset = field<HTMLButtonElement>('reset');
Expand Down Expand Up @@ -105,13 +111,26 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o
focusDate: boundary => (boundary === 'start' ? start : end).focus(),
error: message => error(message || (rebuild.hidden ? '' : rebuildMessage)),
});
const build = (): void => {
const build = (announce = true): void => {
if (gestureFrame) return;
try {
const intent = buildLocalScheduleIntent(readPlanScheduleInput(form));
state = createEditorDraft({ kind: 'create', totalSteps: intent.dates.length, shares: intent.shares, dates: intent.dates });
coverage = intent.coverage; dirty = null; rebuild.hidden = true; error(''); paint();
} catch (failure) { error((failure as Error).message); paint(); }
coverage = intent.coverage; dirty = null; rebuild.hidden = true;
if (editable) error('');
paint();
if (status && summary) {
const count = intent.dates.length;
const text = `Target ${coverage}% in ${count} ${count === 1 ? 'step' : 'steps'}, ${count === 1 ? intent.dates[0] : `${intent.dates[0]} to ${intent.dates[count - 1]}`} (UTC)`;
summary.textContent = text; plotHost.hidden = caption.hidden = false;
if (announce) status.textContent = text;
}
} catch (failure) {
if (editable) { error((failure as Error).message); paint(); return; }
disposeInteraction?.(); state = null; plotHost.replaceChildren(); plotHost.hidden = caption.hidden = true;
summary!.textContent = (failure as Error).message;
if (announce) status!.textContent = (failure as Error).message;
}
};
const commit = (): void => {
if (!state || gestureFrame) return;
Expand All @@ -138,11 +157,12 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o
const settings = (event: Event): void => {
if (gestureFrame) return;
const target = event.target as HTMLInputElement;
if (!state) { if (target.id) build(); return; }
if (target.name !== 'ramp-schedule' && !scheduleSettingIds.includes(target.id)) return;
if (!editable || !state) { build(event.type === 'change'); return; }
if (target.id === 'plan-coverage') {
try { coverage = buildLocalScheduleIntent(readPlanScheduleInput(form)).coverage; if (!dirty && rebuild.hidden) error(''); paint(); }
catch (failure) { error((failure as Error).message); }
} else if (target.name === 'ramp-schedule' || ['ramp-step-percent', 'ramp-interval-days', 'plan-notify-days'].includes(target.id)) {
} else {
if (state && (dirty || draftDateChanges(state).length)) { rebuild.hidden = false; error(rebuildMessage); }
else build();
}
Expand All @@ -151,7 +171,7 @@ export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, o
if (editable && event.key === 'Enter' && [date, start, end].includes(event.target as HTMLInputElement)) { event.preventDefault(); commit(); }
};
reset.onclick = () => { if (editable && state && !gestureFrame) { state = resetEditorDraft(state); dirty = null; error(rebuild.hidden ? '' : rebuildMessage); paint(); } };
rebuild.onclick = build;
rebuild.onclick = () => build();
host.addEventListener('input', input); host.addEventListener('change', change); host.addEventListener('keydown', keydown);
form.addEventListener('input', settings); form.addEventListener('change', settings);
const submit = (event: Event): void => {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/plan-schedule-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { suggestedPurchaseStartDate } from './lib/calendar-date';

export interface PlanScheduleIntentInput {
name: string;
preset: string;
coverage: string;
stepPercent: string;
intervalDays: string;
notificationDays: string;
term: string;
payment: string;
}

export 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 buildLocalScheduleIntent(input: PlanScheduleIntentInput): { coverage: number; notificationDays: number; dates: string[]; shares: number[] } {
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<string, readonly [number, number]> = { 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 dates = Array.from({ length: count }, (_, index) => new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10));
return { coverage, notificationDays: notice, dates, shares: dates.map((_, index) => Math.min(100, (index + 1) * percent)) };
}
Loading
Loading