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
63 changes: 63 additions & 0 deletions frontend/src/__tests__/plan-schedule-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { openPlanSchedulePreview, PlanSchedulePreviewInput } from '../plan-schedule-preview';
import { closeModal } from '../modal';
import { suggestedPurchaseStartDate } from '../lib/calendar-date';

const draft: PlanSchedulePreviewInput = { name: 'Compute ramp', preset: 'weekly-25pct', coverage: '80', stepPercent: '25', intervalDays: '7', notificationDays: '3', term: '1', payment: 'monthly' };
let parent: HTMLElement;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') });
document.body.innerHTML = '<div id="plan-modal"><input value="unchanged"><button id="trigger">Preview</button></div>';
parent = document.getElementById('plan-modal')!;
document.getElementById('trigger')!.focus();
});
afterEach(() => {
const preview = document.getElementById('plan-schedule-preview');
if (preview) closeModal(preview);
jest.useRealTimers();
});

test('shows truthful hypothetical dates, preserves parent values, and restores focus on Escape', () => {
openPlanSchedulePreview(draft, parent);
const preview = document.getElementById('plan-schedule-preview')!;
expect(preview.textContent).toContain('These dates are not saved or scheduled');
expect(preview.textContent).toContain('Planned cumulative target (%)');
expect(preview.textContent).toContain('Amount unavailable');
expect(preview.querySelectorAll('[data-preview-step]')).toHaveLength(4);
expect(parent.inert).toBe(true);
const point = preview.querySelector('[data-preview-step="3"]') as unknown as HTMLElement;
point.dispatchEvent(new Event('click'));
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);
preview.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.getElementById('plan-schedule-preview')).toBeNull();
expect(parent.inert).toBeFalsy();
expect(parent.querySelector('input')?.value).toBe('unchanged');
expect(document.activeElement?.id).toBe('trigger');
});

test('preserves legacy custom33 shares and safely renders text when reopened', () => {
openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33', name: '<img src=x onerror=alert(1)>' }, parent);
expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3);
expect(document.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('79.2%');
expect(document.querySelector('img')).toBeNull();
openPlanSchedulePreview({ ...draft, coverage: '0', preset: 'immediate' }, parent);
expect(document.querySelectorAll('#plan-schedule-preview')).toHaveLength(1);
expect(document.querySelector('[data-preview-step]')?.getAttribute('aria-label')).toContain('target 0%');
openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '1' }, parent);
expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(100);
});

test.each([
{ coverage: '' }, { coverage: '101' }, { coverage: '1.5' }, { notificationDays: '0' }, { notificationDays: 'Infinity' },
{ preset: 'toString' }, { preset: 'custom', stepPercent: '0' }, { preset: 'custom', intervalDays: '366' },
])('rejects invalid draft %j without hiding the form', change => {
expect(() => openPlanSchedulePreview({ ...draft, ...change }, parent)).toThrow();
expect(document.getElementById('plan-schedule-preview')).toBeNull();
expect(parent.inert).toBeFalsy();
});

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');
});
14 changes: 14 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,20 @@ describe('Plans Module', () => {
});

describe('loadPlans', () => {
test('wires actual form values to the no-write schedule preview', () => {
const button = document.createElement('button');
button.id = 'preview-plan-schedule-btn';
document.getElementById('plan-modal')!.appendChild(button);
(document.querySelector('input[value="custom"]') as HTMLInputElement).checked = true;
(document.getElementById('ramp-step-percent') as HTMLInputElement).value = '33';
setupPlanHandlers();
button.click();
expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3);
expect(api.createPlan).not.toHaveBeenCalled();
expect(api.updatePlan).not.toHaveBeenCalled();
expect(api.createPlannedPurchases).not.toHaveBeenCalled();
});

test('fetches and renders plans', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({
plans: [
Expand Down
1 change: 1 addition & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ <h3>Target Accounts <span aria-hidden="true" class="required-asterisk">*</span><

<div class="modal-buttons">
<button type="button" id="close-plan-modal-btn">Cancel</button>
<button type="button" id="preview-plan-schedule-btn">Preview schedule</button>
<button type="submit" class="primary">Save Plan</button>
</div>
</form>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/calendar-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function suggestedPurchaseStartDate(noticeDays: number, now = new Date()): string {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + noticeDays + 2)).toISOString().slice(0, 10);
}
111 changes: 111 additions & 0 deletions frontend/src/plan-schedule-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { closeModal, openModal } from './modal';
import { escapeHtml } from './utils';
import { suggestedPurchaseStartDate } from './lib/calendar-date';

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

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 openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: HTMLElement): void {
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 points = Array.from({ length: count }, (_, index) => ({
date: new Date(start + index * interval * 86_400_000).toISOString().slice(0, 10),
target: coverage * Math.min(100, (index + 1) * percent) / 100,
}));
const previous = document.getElementById('plan-schedule-preview');
if (previous) { closeModal(previous); previous.remove(); }
const modal = document.createElement('div');
modal.id = 'plan-schedule-preview';
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'schedule-preview-title');
const width = Math.max(640, count * 48 + 96);
const x = (index: number): number => count === 1 ? width / 2 : 48 + index * (width - 96) / (count - 1);
const y = (target: number): number => 260 - target * 2.2;
let path = `M 48 ${y(0)}`;
points.forEach((point, index) => { path += ` H ${x(index)} V ${y(point.target)}`; });
modal.innerHTML = `
<div class="modal-content schedule-preview-panel">
<header><h2 id="schedule-preview-title">Schedule preview</h2><p>${escapeHtml(input.name)}</p></header>
<div class="schedule-preview-body">
<p class="schedule-preview-notice">Draft preview. These dates are not saved or scheduled. Saved purchases and history are not shown.</p>
<div class="schedule-preview-layout">
<section aria-label="Draft schedule">
<h3>Planned cumulative target (%)</h3>
<p class="help-text">Hypothetical dates, not observed utilization. All dates use UTC.</p>
<div class="schedule-preview-scroll" tabindex="0" aria-label="Scrollable draft calendar">
<svg viewBox="0 0 ${width} 310" width="${width}" role="group" aria-label="Hypothetical purchase dates">
${[0, 20, 40, 60, 80, 100].map(value => `<path class="preview-grid" d="M 48 ${y(value)} H ${width - 24}"/><text x="8" y="${y(value) + 4}">${value}</text>`).join('')}
<path class="preview-staircase" d="${path}"/>
${points.map((point, index) => `<g role="button" tabindex="${index === 0 ? 0 : -1}" data-preview-step="${index}" aria-pressed="${index === 0}" aria-label="Step ${index + 1}, ${point.date}, target ${point.target}%">
<circle class="preview-hit" cx="${x(index)}" cy="${y(point.target)}" r="22"/>
<circle class="preview-marker" cx="${x(index)}" cy="${y(point.target)}" r="6"/>
${index % Math.ceil(count / 5) === 0 || index === count - 1 ? `<text x="${x(index)}" y="292" text-anchor="middle">${point.date.slice(5)}</text>` : ''}
</g>`).join('')}
</svg>
</div>
<p class="help-text">○ Draft date · Select a point to inspect it. Arrow keys move between steps.</p>
</section>
<aside aria-label="Read-only plan summary">
<h3>Plan summary</h3>
<dl><dt>Target</dt><dd>${coverage}%</dd><dt>Steps</dt><dd>${count}</dd><dt>Start</dt><dd>${points[0]!.date}</dd><dt>End</dt><dd>${points[count - 1]!.date}</dd></dl>
<h3 id="preview-selected-step">Step 1</h3>
<label>Preview date (UTC)<input id="preview-selected-date" type="date" readonly value="${points[0]!.date}"></label>
<p id="preview-selected-target" role="status"></p>
<p class="help-text">Amount unavailable in this draft preview.</p>
<details><summary>Advanced settings</summary><dl><dt>Term</dt><dd>${escapeHtml(input.term)} years</dd><dt>Payment</dt><dd>${escapeHtml(input.payment)}</dd><dt>Notification window</dt><dd>${notice} days</dd></dl></details>
</aside>
</div>
</div>
<footer><button type="button" id="close-schedule-preview">Close preview</button></footer>
</div>`;
document.body.appendChild(modal);
const buttons = Array.from(modal.querySelectorAll<SVGGElement>('[data-preview-step]'));
const select = (index: number, focus: boolean): void => {
const point = points[index]!;
buttons.forEach((button, i) => { button.setAttribute('aria-pressed', String(i === index)); button.setAttribute('tabindex', i === index ? '0' : '-1'); });
modal.querySelector('#preview-selected-step')!.textContent = `Step ${index + 1}`;
(modal.querySelector('#preview-selected-date') as HTMLInputElement).value = point.date;
modal.querySelector('#preview-selected-target')!.textContent = `Planned target: ${point.target}%`;
if (focus) buttons[index]!.focus();
};
buttons.forEach((button, index) => {
button.addEventListener('click', () => select(index, true));
button.addEventListener('keydown', event => {
if (['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.preventDefault();
const next = event.key === 'ArrowLeft' ? Math.max(0, index - 1) : event.key === 'ArrowRight' ? Math.min(count - 1, index + 1) : index;
select(next, true);
}
});
});
modal.querySelector('#close-schedule-preview')!.addEventListener('click', () => closeModal(modal));
const wasInert = parent.inert;
parent.inert = true;
select(0, false);
openModal(modal, { onClose: () => { parent.inert = wasInert; modal.remove(); } });
}
15 changes: 14 additions & 1 deletion frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { openModal, closeModal } from './modal';
import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/skeleton';
import { canAccess } from './permissions';
import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters';
import { suggestedPurchaseStartDate } from './lib/calendar-date';
import { openPlanSchedulePreview } from './plan-schedule-preview';

// pendingPlanRecommendations holds the resolved plan target captured at
// "Plan from N selected" button-click time. The Plan flow used to re-derive
Expand Down Expand Up @@ -2251,7 +2253,7 @@ export async function openAddPurchasesModal(planId: string, planName: string, no
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);
const suggested = suggestedPurchaseStartDate(notificationDays, now);
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 => {
Expand Down Expand Up @@ -2366,6 +2368,17 @@ let _unsubAccount: (() => void) | null = null;
* the page level.
*/
export function setupPlanHandlers(): void {
const preview = document.getElementById('preview-plan-schedule-btn');
if (preview) preview.onclick = () => {
const value = (id: string): string => (document.getElementById(id) as HTMLInputElement | HTMLSelectElement | null)?.value ?? '';
const parent = document.getElementById('plan-modal');
if (!parent) return;
try {
openPlanSchedulePreview({ name: value('plan-name'), preset: document.querySelector<HTMLInputElement>('input[name="ramp-schedule"]:checked')?.value ?? '',
coverage: value('plan-coverage'), stepPercent: value('ramp-step-percent'), intervalDays: value('ramp-interval-days'),
notificationDays: value('plan-notify-days'), term: value('plan-term'), payment: value('plan-payment') }, parent);
} catch (error) { showToast({ message: (error as Error).message, kind: 'error' }); }
};
// Drop any previous subscriptions before re-registering so we don't
// accumulate duplicate loadPlans() calls on each invocation.
_unsubProvider?.();
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/styles/plans.css
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,39 @@
background: var(--cudly-surface-muted);
border-radius: 8px;
}

#plan-schedule-preview { z-index: 1010; }
#plan-schedule-preview .schedule-preview-panel {
width: min(1280px, calc(100vw - 48px)); max-width: none;
max-height: calc(100dvh - 48px); padding: 0; overflow: hidden;
display: flex; flex-direction: column;
}
.schedule-preview-panel header, .schedule-preview-panel footer { padding: 20px 24px; flex: none; }
.schedule-preview-panel header { display: block; background: var(--cudly-surface); color: var(--cudly-text); border-bottom: 1px solid var(--cudly-border); }
.schedule-preview-panel header h2 { margin: 0; font-size: 22px; }
.schedule-preview-panel header p { margin: 6px 0 0; color: var(--cudly-text-muted); overflow-wrap: anywhere; }
.schedule-preview-panel footer { border-top: 1px solid var(--cudly-border); text-align: right; }
.schedule-preview-body { padding: 24px; overflow: auto; min-height: 0; }
.schedule-preview-notice { margin: 0 0 24px; padding: 12px; background: var(--cudly-info-bg); border-radius: 8px; }
.schedule-preview-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; }
.schedule-preview-layout h3 { margin: 0 0 12px; font-size: 16px; }
.schedule-preview-scroll { overflow: auto; max-width: 100%; border: 1px solid var(--cudly-border); border-radius: 8px; }
.schedule-preview-scroll svg { display: block; max-width: none; height: 310px; }
.schedule-preview-scroll text { fill: var(--cudly-text-muted); font: 12px system-ui; font-variant-numeric: tabular-nums; pointer-events: none; }
.preview-grid { stroke: var(--cudly-border); fill: none; pointer-events: none; }
.preview-staircase { stroke: var(--cudly-primary); stroke-width: 2; fill: none; pointer-events: none; }
.preview-hit { fill: transparent; cursor: pointer; }
.preview-marker { fill: var(--cudly-surface); stroke: var(--cudly-primary); stroke-width: 2; pointer-events: none; }
[data-preview-step][aria-pressed="true"] .preview-marker { fill: var(--cudly-primary); stroke-width: 4; }
[data-preview-step]:focus-visible .preview-marker { stroke: var(--cudly-text); stroke-width: 5; }
.schedule-preview-layout dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0 0 24px; }
.schedule-preview-layout dt { color: var(--cudly-text-muted); }
.schedule-preview-layout dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.schedule-preview-layout input[readonly] { display: block; width: 100%; padding: 8px; border: 1px solid var(--cudly-border); border-radius: 4px; }
.schedule-preview-layout details { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--cudly-border); }
.schedule-preview-layout summary { cursor: pointer; margin-bottom: 12px; }
@media (max-width: 900px) {
#plan-schedule-preview .schedule-preview-panel { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
.schedule-preview-layout { grid-template-columns: minmax(0, 1fr); }
.schedule-preview-body { padding: 16px; }
}
Loading
Loading