diff --git a/frontend/src/__tests__/ladder-timeline-filters.test.ts b/frontend/src/__tests__/ladder-timeline-filters.test.ts index fd84d73a..a1af45a2 100644 --- a/frontend/src/__tests__/ladder-timeline-filters.test.ts +++ b/frontend/src/__tests__/ladder-timeline-filters.test.ts @@ -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', @@ -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'); +}); diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 50f17839..366458bf 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -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(`[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('#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 }; diff --git a/frontend/src/ladder-timeline-filters.ts b/frontend/src/ladder-timeline-filters.ts index 11c7912f..f73694db 100644 --- a/frontend/src/ladder-timeline-filters.ts +++ b/frontend/src/ladder-timeline-filters.ts @@ -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.'; diff --git a/frontend/src/ladder-timeline.ts b/frontend/src/ladder-timeline.ts index f71b1c31..9cec6435 100644 --- a/frontend/src/ladder-timeline.ts +++ b/frontend/src/ladder-timeline.ts @@ -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'; @@ -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 = `

Planned purchases

Inspect retained planning tranches and edit future scheduled budgets. + const group = (id: string, label: string, control: string): string => `

${control}
`; + const dateInput = (edge: 'from' | 'to'): string => ``; + root.innerHTML = `
Planned purchases +

Inspect retained planning tranches and edit future scheduled budgets. Ladder purchases are not executed yet. Coverage and expiry forecasts are unavailable.

-
-
-

`; const provider = root.querySelector('[data-provider]')!; const account = root.querySelector('[data-account]')!; const state = root.querySelector('[data-state]')!; const content = root.querySelector('[data-content]')!; const summary = root.querySelector('[data-summary]')!; const table = root.querySelector('[data-table]')!; - root.querySelector('[data-date="from"]')!.value = filters.from; + const dateError = root.querySelector('#ladder-timeline-date-error')!; + root.querySelector('[data-date="from"]')!.value = formatLadderUtcInput(filters.from); const focusEvent = (id: string): void => { Array.from(table.querySelectorAll('[data-event]')).find(button => button.dataset['event'] === id)?.focus(); }; @@ -154,13 +159,32 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La }; const resetFilters = (): void => { filters = emptyLadderFilters(); - root.querySelectorAll('[data-date]').forEach(input => { input.value = ''; }); + root.querySelectorAll('[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('[data-filter]').forEach(select => select.addEventListener('change', () => { filters[select.dataset['filter'] as typeof ladderDimensions[number]] = select.value; render(); })); - root.querySelectorAll('[data-date]').forEach(input => input.addEventListener('change', () => { filters[input.dataset['date'] as 'from' | 'to'] = input.value; render(); })); + root.querySelectorAll('[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(); } diff --git a/frontend/src/ladder.ts b/frontend/src/ladder.ts index 897d8db5..3c9e6ecf 100644 --- a/frontend/src/ladder.ts +++ b/frontend/src/ladder.ts @@ -126,8 +126,9 @@ function renderLadderingSection(globalEnabled: boolean): string { Add Account Config ` : ''} -
+ +