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
19 changes: 18 additions & 1 deletion frontend/src/__tests__/ladder-timeline-filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LadderEvent } from '../api/ladder';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime, ladderCumulative, ladderNotCountedNote } from '../ladder-timeline-filters';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime, ladderCumulative, ladderNotCountedNote, parseLadderUtcInput, formatLadderUtcInput } from '../ladder-timeline-filters';

const event: LadderEvent = {
id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr',
Expand Down Expand Up @@ -91,3 +91,20 @@ test('ladderCumulative counts only scheduled, fired and completed tranches (#698
expect(rows.map(ladderNotCountedNote)).toEqual([undefined, 'Not counted', 'Not counted', 'Not counted (unknown status)', undefined, undefined]);
expect(ladderCumulative([at('c', 'cancelled', '5.000000', 1)], 0n).times).toEqual([]);
});

test('typed UTC filter dates parse strictly and whole days cover both edges (issue #753)', () => {
expect(parseLadderUtcInput('', 'from')).toBe('');
expect(parseLadderUtcInput(' ', 'to')).toBe('');
expect(parseLadderUtcInput('2030-01-02', 'from')).toBe('2030-01-02T00:00:00');
expect(parseLadderUtcInput('2030-01-02', 'to')).toBe('2030-01-02T23:59:59');
expect(parseLadderUtcInput('2030-01-02 08:30', 'from')).toBe('2030-01-02T08:30:00');
expect(parseLadderUtcInput('2030-01-02T08:30:15', 'to')).toBe('2030-01-02T08:30:15');
expect(parseLadderUtcInput(' 2030-01-02 08:30 UTC ', 'from')).toBe('2030-01-02T08:30:00');
expect(parseLadderUtcInput('2030-01-02T08:30Z', 'from')).toBe('2030-01-02T08:30:00');
for (const bad of ['02.01.2030', '01/02/2030', '2030-02-30', '2030-13-01', '2030-01-02 24:00', '2030-01-02 8:30', 'tomorrow']) {
expect(parseLadderUtcInput(bad, 'from')).toBeNull();
}
expect(formatLadderUtcInput('')).toBe('');
expect(formatLadderUtcInput('2030-01-02T08:30:00')).toBe('2030-01-02 08:30');
expect(formatLadderUtcInput('2030-01-02T23:59:59')).toBe('2030-01-02 23:59:59');
});
41 changes: 41 additions & 0 deletions frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,47 @@ test('editor labels each row by its basis and keeps the native amount', async ()
expect(open('m3')).toBe('Planning budget (on-demand-equivalent $/hr)|1.000000');
});

describe('planned purchases layout and UTC date filters (issue #753)', () => {
const date = (edge: 'from' | 'to'): HTMLInputElement => document.querySelector<HTMLInputElement>(`[data-date="${edge}"]`)!;
const type = (edge: 'from' | 'to', value: string): void => { date(edge).value = value; date(edge).dispatchEvent(new Event('change')); };
const error = (): HTMLElement => document.getElementById('ladder-timeline-date-error')!;
const summary = (): string => document.querySelector('[data-summary]')!.textContent ?? '';

test('renders its own card with labelled controls and plain-text UTC dates', async () => {
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const card = document.querySelector('#timeline > fieldset.settings-category')!;
expect(card.querySelector('legend')).toHaveTextContent('Planned purchases');
card.querySelectorAll('.ladder-timeline-controls select, .ladder-timeline-controls input').forEach(control => {
expect(card.querySelector(`label[for="${control.id}"]`)).not.toBeNull();
expect(control.closest('.form-group')).not.toBeNull();
});
expect(card.querySelector('input[type="datetime-local"]')).toBeNull();
expect(date('from').type).toBe('text');
expect(date('from').value).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/);
expect(date('to')).toHaveAttribute('placeholder', 'YYYY-MM-DD HH:MM');
expect(card.querySelector('[data-reset]')).toHaveClass('btn-tertiary');
});

test('valid dates filter and normalize; invalid dates are flagged and ignored until cleared', async () => {
await initLadderTimeline(document.getElementById('timeline')!, [], false);
type('from', '');
type('to', '2029-12-31');
expect(date('to').value).toBe('2029-12-31 23:59:59');
expect(summary()).toContain('0 of 1');
type('to', '2030-01-01');
expect(summary()).toContain('1 of 1');
type('to', '01.01.2029');
expect(date('to')).toHaveAttribute('aria-invalid', 'true');
expect(error()).not.toHaveClass('hidden');
expect(error()).toHaveTextContent('To date "01.01.2029" is not a valid UTC date');
expect(summary()).toContain('1 of 1');
document.querySelector<HTMLButtonElement>('#timeline [data-reset]')!.click();
expect(date('to')).not.toHaveAttribute('aria-invalid');
expect(error()).toHaveClass('hidden');
expect(date('to').value).toBe('');
});
});

describe('chart teardown on navigation (issue #667)', () => {
const chartInstance = (index: number): { destroy: jest.Mock } => jest.mocked(Chart).mock.results[index]!.value as { destroy: jest.Mock };

Expand Down
29 changes: 29 additions & 0 deletions frontend/src/ladder-timeline-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,35 @@ export function formatLadderDateTime(iso: string): string {
return `${formatDate(utc.slice(0, 10))}, ${utc.slice(11, 16)} UTC`;
}

const UTC_INPUT = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?(?:\s*(?:Z|UTC))?$/i;

/**
* Parses a typed UTC filter date ("YYYY-MM-DD", optionally with "HH:MM" or
* "HH:MM:SS", "T" or a space between) into the filter's "YYYY-MM-DDTHH:MM:SS"
* form. A date without a time covers the whole day: 00:00:00 for "from" and
* 23:59:59 for "to". Returns '' for blank input and null when it is not a real
* date, so callers never guess at an ambiguous value.
*/
export function parseLadderUtcInput(text: string, edge: 'from' | 'to'): string | null {
const trimmed = text.trim();
if (!trimmed) return '';
const match = UTC_INPUT.exec(trimmed);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
const time = hour === undefined ? (edge === 'from' ? '00:00:00' : '23:59:59') : `${hour}:${minute}:${second ?? '00'}`;
const value = `${year}-${month}-${day}T${time}`;
const stamp = Date.parse(`${value}Z`);
// Date.parse rolls 2026-02-30 over to March; the round trip rejects it.
return Number.isFinite(stamp) && new Date(stamp).toISOString().slice(0, 19) === value ? value : null;
}

/** Shows a "YYYY-MM-DDTHH:MM:SS" filter value as "YYYY-MM-DD HH:MM", keeping seconds only when set. */
export function formatLadderUtcInput(value: string): string {
if (!value) return '';
const [date, time = '00:00:00'] = value.split('T');
return `${date} ${time.endsWith(':00') ? time.slice(0, 5) : time}`;
}

/** Statuses that count toward cumulative commitment; cancelled and failed tranches were never bought. */
export const ladderCountedStatuses: readonly string[] = ['scheduled', 'fired', 'completed'];
export const LADDER_COUNTED_NOTE = 'Counts scheduled, fired and completed tranches; cancelled, failed and unrecognised statuses are excluded.';
Expand Down
54 changes: 39 additions & 15 deletions frontend/src/ladder-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { listAccountsMinimal, type AccountSummary } from './api/accounts';
import { getLadderEvents, getLadderRuns, type LadderConfig, type LadderEvent, type LadderRun } from './api/ladder';
import { canAccess } from './permissions';
import { escapeHtml } from './utils';
import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotalOd, ladderOdAmount, ladderLabel, formatLadderAmount, formatLadderUnits, ladderNotCountedNote, LADDER_COUNTED_NOTE, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters';
import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotalOd, ladderOdAmount, ladderLabel, formatLadderAmount, formatLadderUnits, ladderNotCountedNote, LADDER_COUNTED_NOTE, formatLadderDateTime, formatLadderUtcInput, parseLadderUtcInput, type LadderFilters } from './ladder-timeline-filters';
import { openLadderEventEditor } from './ladder-timeline-editor';
import { ladderChartConfig, ladderChartModel, ladderChartSummary, LADDER_UNIT_LABEL } from './ladder-timeline-chart';

Expand Down Expand Up @@ -38,26 +38,31 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
let selectedID = '';
disposeTimeline = () => { disposed = true; generation++; chart?.destroy(); chart = undefined; };
const labels = { run_id: 'Run', layer_type: 'Commitment type', term: 'Commitment term', payment_option: 'Payment option', status: 'Status' };
root.innerHTML = `<h4>Planned purchases</h4><p>Inspect retained planning tranches and edit future scheduled budgets.
const group = (id: string, label: string, control: string): string => `<div class="form-group"><label for="ladder-timeline-${id}">${label}</label>${control}</div>`;
const dateInput = (edge: 'from' | 'to'): string => `<input type="text" id="ladder-timeline-${edge}" data-date="${edge}" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="YYYY-MM-DD HH:MM" aria-describedby="ladder-timeline-date-hint ladder-timeline-date-error">`;
root.innerHTML = `<fieldset class="settings-category ladder-timeline"><legend>Planned purchases</legend>
<p class="settings-help">Inspect retained planning tranches and edit future scheduled budgets.
Ladder purchases are not executed yet. Coverage and expiry forecasts are unavailable.</p>
<div class="ladder-timeline-controls"><label>Cloud provider <select data-provider><option value="aws">AWS</option><option value="azure">Azure</option><option value="gcp">GCP</option></select></label>
<label>Cloud account <select data-account></select></label><button type="button" data-reload>Reload timeline</button></div>
<p data-state role="status"></p><div data-content hidden>
<div class="ladder-timeline-controls">${ladderDimensions.map(key => `<label>${labels[key]} <select data-filter="${key}"></select></label>`).join('')}
<label>From (UTC) <input type="datetime-local" step="1" data-date="from"></label>
<label>To (UTC) <input type="datetime-local" step="1" data-date="to"></label>
<button type="button" data-reset>Clear filters</button></div>
<p>Service, instance type, region and currency filters are unavailable because saved ladder tranches do not contain those fields.</p>
<p data-summary aria-live="polite"></p><p data-snapshot></p>
<div class="ladder-timeline-controls">${group('provider', 'Cloud provider', '<select id="ladder-timeline-provider" data-provider><option value="aws">AWS</option><option value="azure">Azure</option><option value="gcp">GCP</option></select>')}
${group('account', 'Cloud account', '<select id="ladder-timeline-account" data-account></select>')}<button type="button" class="btn btn-secondary" data-reload>Reload timeline</button></div>
<p class="ladder-timeline-state" data-state role="status"></p><div data-content hidden>
<div class="ladder-timeline-controls">${ladderDimensions.map(key => group(key, labels[key], `<select id="ladder-timeline-${key}" data-filter="${key}"></select>`)).join('')}
${group('from', 'From (UTC)', dateInput('from'))}${group('to', 'To (UTC)', dateInput('to'))}
<button type="button" class="btn btn-tertiary" data-reset>Clear filters</button></div>
<p class="ladder-timeline-note" id="ladder-timeline-date-hint">Enter dates in UTC as YYYY-MM-DD HH:MM (24-hour); the time is optional.</p>
<p class="field-error hidden" id="ladder-timeline-date-error" role="alert"></p>
<p class="ladder-timeline-stat" data-summary aria-live="polite"></p><p class="ladder-timeline-note" data-snapshot></p>
<div class="ladder-timeline-chart"><canvas aria-label="Cumulative planned commitment chart. The purchase table below lists the same figures." role="img"></canvas></div>
<div data-table></div></div>`;
<div data-table></div>
<p class="ladder-timeline-note">Service, instance type, region and currency filters are unavailable because saved ladder tranches do not contain those fields.</p></div></fieldset>`;
const provider = root.querySelector<HTMLSelectElement>('[data-provider]')!;
const account = root.querySelector<HTMLSelectElement>('[data-account]')!;
const state = root.querySelector<HTMLElement>('[data-state]')!;
const content = root.querySelector<HTMLElement>('[data-content]')!;
const summary = root.querySelector<HTMLElement>('[data-summary]')!;
const table = root.querySelector<HTMLElement>('[data-table]')!;
root.querySelector<HTMLInputElement>('[data-date="from"]')!.value = filters.from;
const dateError = root.querySelector<HTMLElement>('#ladder-timeline-date-error')!;
root.querySelector<HTMLInputElement>('[data-date="from"]')!.value = formatLadderUtcInput(filters.from);
const focusEvent = (id: string): void => {
Array.from(table.querySelectorAll<HTMLButtonElement>('[data-event]')).find(button => button.dataset['event'] === id)?.focus();
};
Expand Down Expand Up @@ -154,13 +159,32 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
};
const resetFilters = (): void => {
filters = emptyLadderFilters();
root.querySelectorAll<HTMLInputElement>('[data-date]').forEach(input => { input.value = ''; });
root.querySelectorAll<HTMLInputElement>('[data-date]').forEach(input => { input.value = ''; input.removeAttribute('aria-invalid'); });
showDateError('');
};
const showDateError = (message: string): void => {
dateError.textContent = message;
dateError.classList.toggle('hidden', !message);
};
provider.addEventListener('change', () => { resetFilters(); setAccounts(); void load(); });
account.addEventListener('change', () => { resetFilters(); void load(); });
root.querySelector('[data-reload]')!.addEventListener('click', () => { void load(); });
root.querySelectorAll<HTMLSelectElement>('[data-filter]').forEach(select => select.addEventListener('change', () => { filters[select.dataset['filter'] as typeof ladderDimensions[number]] = select.value; render(); }));
root.querySelectorAll<HTMLInputElement>('[data-date]').forEach(input => input.addEventListener('change', () => { filters[input.dataset['date'] as 'from' | 'to'] = input.value; render(); }));
root.querySelectorAll<HTMLInputElement>('[data-date]').forEach(input => input.addEventListener('change', () => {
const edge = input.dataset['date'] as 'from' | 'to';
const value = parseLadderUtcInput(input.value, edge);
// An unparseable date keeps the last valid filter instead of guessing.
if (value === null) {
input.setAttribute('aria-invalid', 'true');
showDateError(`${edge === 'from' ? 'From' : 'To'} date "${input.value.trim()}" is not a valid UTC date. Use YYYY-MM-DD HH:MM.`);
return;
}
input.removeAttribute('aria-invalid');
if (!root.querySelector('[data-date][aria-invalid="true"]')) showDateError('');
filters[edge] = value;
input.value = formatLadderUtcInput(value);
render();
}));
root.querySelector('[data-reset]')!.addEventListener('click', () => { resetFilters(); render(); });
await load();
}
3 changes: 2 additions & 1 deletion frontend/src/ladder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ function renderLadderingSection(globalEnabled: boolean): string {
Add Account Config
</button>` : ''}
</div>
<div id="ladder-purchase-timeline"></div>
</fieldset>
<!-- Planned purchases renders its own settings card (issue #753). -->
<div id="ladder-purchase-timeline"></div>

<!-- Per-account ladder config modal -->
<div id="ladder-config-modal" class="modal hidden" role="dialog" aria-modal="true"
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/styles/charts.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,26 @@
margin: auto;
color: var(--cudly-primary, #2563eb);
}
/* Planned purchases card (issue #753): filter rows of label-above-control
* groups, one stat line, and muted footnotes. */
.ladder-timeline-controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 1rem;
align-items: flex-end;
gap: var(--cudly-sp-3) var(--cudly-sp-4);
margin-bottom: var(--cudly-sp-3);
}
.ladder-timeline-controls label { display: grid; gap: 0.25rem; }
.ladder-timeline-controls .form-group { margin-bottom: 0; min-width: 9rem; }
.ladder-timeline-controls .form-group label {
margin-bottom: var(--cudly-sp-1);
font-size: var(--cudly-fs-xs);
font-weight: 500;
color: var(--cudly-text-muted);
}
.ladder-timeline-controls input[data-date] { width: 11.5rem; font-variant-numeric: tabular-nums; }
.ladder-timeline-state { margin: 0 0 var(--cudly-sp-3); }
.ladder-timeline-stat { margin: var(--cudly-sp-3) 0 var(--cudly-sp-1); font-weight: 600; }
.ladder-timeline-note { margin: 0 0 var(--cudly-sp-2); font-size: var(--cudly-fs-sm); color: var(--cudly-text-muted); }
.ladder-timeline-chart { height: 320px; margin: 1rem 0; }
#ladder-purchase-timeline [data-table] { overflow-x: auto; }
.ladder-selected { outline: 2px solid #2563eb; outline-offset: -2px; }
Expand Down
Loading
Loading