From ad0a97e566c3092406573e72f3d037a49e5f6ace Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 22:39:37 +0200 Subject: [PATCH 1/3] fix(ladder): format timeline amounts, dates and enum labels for display Table, filters, summary, snapshot, tooltip and editor showed raw 6-decimal USD strings, ISO timestamps and enum codes. Reuse formatCurrency, formatDate, formatTerm and getPaymentLabel; add a label map for layer and status enums that shows unknown codes raw. Dates render in UTC, amounts under $1 keep four digits, and absent snapshots read 'unavailable' rather than zero. Closes #697 Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/ladder-timeline-filters.test.ts | 29 ++++++++++- .../src/__tests__/ladder-timeline.test.ts | 48 ++++++++++++++++++- frontend/src/ladder-timeline-editor.ts | 10 ++-- frontend/src/ladder-timeline-filters.ts | 41 ++++++++++++++++ frontend/src/ladder-timeline.ts | 14 +++--- frontend/tests-e2e/ladder-timeline.spec.ts | 8 ++-- 6 files changed, 132 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/ladder-timeline-filters.test.ts b/frontend/src/__tests__/ladder-timeline-filters.test.ts index 793889c4..7307bdfe 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 } from '../ladder-timeline-filters'; +import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime } from '../ladder-timeline-filters'; const event: LadderEvent = { id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', @@ -34,3 +34,30 @@ test('eligibility covers status, execution, deadline, and unavailable revision A expect(ladderEditReason(event, Date.parse(event.scheduled_date))).toContain('passed'); expect(ladderEditReason({ ...event, revision: undefined as unknown as number })).toContain('upgraded'); }); + +// FIXTURE: enum codes from cloud-commitments-go pkg/ladder. +test('ladder labels cover every known enum and show unknown codes raw', () => { + expect(['ec2-instance-sp', 'compute-sp', 'convertible-ri', 'azure-reservation', 'azure-savings-plan'].map(v => ladderLabel('layer_type', v))) + .toEqual(['EC2 Instance Savings Plan', 'Compute Savings Plan', 'Convertible RI', 'Azure Reservation', 'Azure Savings Plan']); + expect(['scheduled', 'fired', 'completed', 'cancelled', 'failed'].map(v => ladderLabel('status', v))) + .toEqual(['Scheduled', 'Fired', 'Completed', 'Cancelled', 'Failed']); + expect(['1yr', '3yr'].map(v => ladderLabel('term', v))).toEqual(['1 Year', '3 Years']); + expect(['no-upfront', 'partial-upfront', 'all-upfront'].map(v => ladderLabel('payment_option', v))).toEqual(['No Upfront', 'Partial Upfront', 'All Upfront']); + expect(ladderLabel('layer_type', 'new-thing')).toBe('new-thing'); + expect(ladderLabel('term', '5yrs')).toBe('5yrs'); + expect(ladderLabel('status', '')).toBe(''); +}); + +test('amounts keep zero and absent distinct and never round a small value to $0.00', () => { + expect(formatLadderAmount('0.000000')).toBe('$0.00/hr'); + expect(formatLadderAmount(null)).toBe('unavailable'); + expect(formatLadderAmount('0.001200')).toBe('$0.0012/hr'); + expect(formatLadderAmount('1234.500000')).toBe('$1,234.50/hr'); + expect(() => formatLadderAmount('abc')).toThrow('Invalid planning budget.'); +}); + +test('dates render the UTC calendar day and time regardless of browser zone', () => { + expect(formatLadderDateTime('2030-01-02T23:30:00Z')).toBe('Jan 2, 2030, 23:30 UTC'); + expect(formatLadderDateTime('2030-01-02T23:30:00-05:00')).toBe('Jan 3, 2030, 04:30 UTC'); + expect(formatLadderDateTime('garbage')).toBe('garbage'); +}); diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 0cab49bc..ee3b1410 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -26,7 +26,7 @@ test('real selection opens editor, UTC draft resets and cancel preserves authori document.querySelector('[data-event]')!.click(); const amount = document.getElementById('ladder-event-amount') as HTMLInputElement; amount.value = '0.5'; amount.dispatchEvent(new Event('input')); - expect(document.getElementById('ladder-event-preview')!.textContent).toContain('0.500000'); + expect(document.getElementById('ladder-event-preview')!.textContent).toContain('$0.5000/hr'); document.querySelector('.ladder-event-editor [data-reset]')!.click(); expect(amount.value).toBe('1.000000'); document.querySelector('.ladder-event-editor [data-cancel]')!.click(); @@ -187,3 +187,49 @@ test('focus returns to the edited purchase after a successful save and the canva expect(document.querySelector('.ladder-event-editor')).toBeNull(); expect(document.activeElement).toBe(document.querySelector('[data-event="one"]')); }); + +// FIXTURE: hand-written wire shape of GET /api/ladder/tranches and /runs (not captured from a live backend). +test('table, filters, summary, snapshot and tooltip show formatted values, not raw wire values (#697)', async () => { + const raw = { ...event, term: '3yr', payment_option: 'all-upfront', layer_type: 'convertible-ri', amount_usd_hr: '0.123456', scheduled_date: '2030-01-02T23:30:00Z' }; + const unknown = { ...event, id: 'two', layer_type: 'future-layer', amount_usd_hr: '0.000000', scheduled_date: '2030-01-03T00:00:00Z' }; + jest.mocked(getLadderEvents).mockResolvedValue([raw, unknown]); + jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, baseline_usd_hr: '0.000000', existing_usd_hr: null, target_usd_hr: '12.500000' }]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const rows = Array.from(document.querySelectorAll('#timeline tbody tr')).map(row => row.textContent ?? ''); + expect(rows[0]).toContain('Jan 2, 2030, 23:30 UTC'); + expect(rows[0]).toContain('Convertible RI'); + expect(rows[0]).toContain('3 Years'); + expect(rows[0]).toContain('All Upfront'); + expect(rows[0]).toContain('$0.1235/hr'); + expect(rows[0]).toContain('Scheduled'); + expect(rows[0]).not.toMatch(/0\.123456|2030-01-02T|convertible-ri|3yr|all-upfront/); + expect(rows[1]).toContain('future-layer'); + expect(rows[1]).toContain('$0.00/hr'); + const summary = document.querySelector('[data-summary]')!.textContent!; + expect(summary).toContain('$0.1235/hr in this selection'); + expect(summary).not.toContain('USD/hour'); + const layer = document.querySelector('[data-filter="layer_type"]')!; + expect(Array.from(layer.options).map(o => [o.value, o.textContent])).toEqual([['', 'All'], ['convertible-ri', 'Convertible RI'], ['future-layer', 'future-layer']]); + const runFilter = document.querySelector('[data-filter="run_id"]')!; + runFilter.value = 'run'; runFilter.dispatchEvent(new Event('change')); + expect(runFilter.options[1]?.textContent).toBe('Jan 1, 2026, 00:00 UTC'); + const snapshot = document.querySelector('[data-snapshot]')!.textContent!; + expect(snapshot).toContain('baseline $0.00/hr, existing unavailable, target $12.50/hr'); + const chartCalls = jest.mocked(Chart).mock.calls; + const tooltip = chartCalls[chartCalls.length - 1]?.[1].options?.plugins?.tooltip?.callbacks?.label as (c: { datasetIndex: number; dataIndex: number }) => string; + expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Jan 2, 2030, 23:30 UTC: $0.1235/hr (Convertible RI, Scheduled)'); +}); + +// FIXTURE: hand-written editor input. +test('editor header and run ceiling are formatted while the amount input keeps the exact wire value (#697)', async () => { + jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, term: '3yr', payment_option: 'partial-upfront', amount_usd_hr: '0.123456' }]); + jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, total_hourly_commit: '2.500000' }]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + document.querySelector('[data-event]')!.click(); + const text = document.querySelector('.ladder-event-editor')!.textContent!; + expect(text).toContain('Compute Savings Plan / 3 Years / Partial Upfront'); + expect(text).toContain('Original plan: Jan 1, 2026, 00:00 UTC'); + expect(text).toContain('Original run ceiling: $2.50/hr'); + expect(text).toContain('Current run total after this change: $0.1235/hr'); + expect((document.getElementById('ladder-event-amount') as HTMLInputElement).value).toBe('0.123456'); +}); diff --git a/frontend/src/ladder-timeline-editor.ts b/frontend/src/ladder-timeline-editor.ts index 677a32c0..94216694 100644 --- a/frontend/src/ladder-timeline-editor.ts +++ b/frontend/src/ladder-timeline-editor.ts @@ -2,7 +2,7 @@ import type { ApiError } from './api/types'; import { amendLadderEvent, type LadderEvent, type LadderRun } from './api/ladder'; import { openModal, closeModal } from './modal'; import { escapeHtml } from './utils'; -import { formatLadderUnits, ladderAmountUnits, ladderTotal } from './ladder-timeline-filters'; +import { formatLadderAmount, formatLadderDateTime, formatLadderUnits, ladderAmountUnits, ladderLabel, ladderTotal } from './ladder-timeline-filters'; export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblings: readonly LadderEvent[], onReload: () => void): void { const modal = document.createElement('div'); @@ -12,8 +12,8 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin modal.setAttribute('aria-labelledby', 'ladder-event-title'); modal.innerHTML = ``; const provider = root.querySelector('[data-provider]')!; const account = root.querySelector('[data-account]')!; @@ -69,31 +71,34 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La const snapshot = root.querySelector('[data-snapshot]')!; const run = runs.find(item => item.id === filters.run_id); snapshot.textContent = run ? `Original run ${formatLadderDateTime(run.started_at)}: baseline ${formatLadderAmount(run.baseline_usd_hr)}, existing ${formatLadderAmount(run.existing_usd_hr)}, target ${formatLadderAmount(run.target_usd_hr)}. These snapshots are not a coverage forecast. ${run.actions.map(action => `${action.rationale}${action.data_sources?.length ? ` Sources: ${action.data_sources.join(', ')}.` : ''}`).join(' ')}` : 'Select a run to inspect its original baseline, existing commitment and target snapshots.'; - chart?.destroy(); - const groups = Array.from(new Set(visible.map(event => event.layer_type))).map(layer => ({ layer, events: visible.filter(event => event.layer_type === layer) })); - const colors: Record = { 'compute-sp': '#2563eb', 'ec2-instance-sp': '#0891b2', 'convertible-ri': '#7c3aed' }; - chart = new Chart(root.querySelector('canvas')!, { - type: 'scatter', data: { datasets: groups.map(group => ({ label: `${group.layer} (USD/hour)`, backgroundColor: colors[group.layer] ?? '#64748b', - data: group.events.map(event => ({ x: Date.parse(event.scheduled_date), y: Number(event.amount_usd_hr) })), - pointRadius: group.events.map(event => event.id === selectedID ? 8 : 5), - pointBackgroundColor: group.events.map(event => event.status === 'scheduled' ? colors[group.layer] ?? '#64748b' : '#64748b'), - })) }, options: { responsive: true, maintainAspectRatio: false, - scales: { x: { type: 'linear', title: { display: true, text: 'Scheduled date (UTC)' }, ticks: { callback: value => new Date(Number(value)).toISOString().slice(0, 10) } }, - y: { beginAtZero: true, title: { display: true, text: 'Additional planning budget (USD/hour)' } } }, - plugins: { legend: { onClick: (_event, item) => { const group = groups[item.datasetIndex ?? -1]; if (group) { filters.layer_type = filters.layer_type === group.layer ? '' : group.layer; render(); } } }, tooltip: { callbacks: { label: context => { - const event = groups[context.datasetIndex]?.events[context.dataIndex]; - return event ? `${formatLadderDateTime(event.scheduled_date)}: ${formatLadderAmount(event.amount_usd_hr)} (${ladderLabel('layer_type', event.layer_type)}, ${ladderLabel('status', event.status)})` : ''; - } } } }, onClick: (_event, points) => { const point = points[0]; const item = point && groups[point.datasetIndex]?.events[point.index]; if (item) selectEvent(item); }, - }, - }); + const canvas = root.querySelector('canvas')!; + const chartBox = canvas.parentElement!; + if (visible.length === 0) { + chart?.destroy(); chart = undefined; + chartBox.hidden = true; + } else { + chartBox.hidden = false; + const model = ladderChartModel(visible, run); + const selected = visible.find(event => event.id === selectedID); + const config = ladderChartConfig(model, selected && Date.parse(selected.scheduled_date), { + legendClick: layer => { filters.layer_type = filters.layer_type === layer ? '' : layer; render(); }, + pointClick: (series, index) => { + const item = visible.find(event => event.layer_type === series.layer && Date.parse(event.scheduled_date) === model.cumulative.times[index]); + if (item) selectEvent(item); + }, + }); + canvas.setAttribute('aria-label', ladderChartSummary(model)); + rowTotals = model.cumulative.rowTotals; + if (chart) { chart.data = config.data; chart.options = config.options!; chart.update('none'); } else chart = new Chart(canvas, config); + } if (visible.length === 0) { const rationale = runs.flatMap(item => item.actions.map(action => action.rationale)).filter(Boolean).join(' '); table.textContent = events.length ? 'No purchases match these filters. Clear filters to see the retained plan.' : `No planned purchase tranches. ${rationale}`; return; } - table.innerHTML = `${visible.map(event => { + table.innerHTML = `
Scheduled (UTC)TypeTermPaymentBudgetStatusSelect or edit
${visible.map(event => { const reason = ladderEditReason(event); - return ``; + return ``; }).join('')}
Planned purchases with running cumulative total. The base is the selected run's existing commitment, or 0 (planned additions only) when no run is selected.
Scheduled (UTC)TypeTermPaymentBudget (${LADDER_UNIT_LABEL.toLowerCase()})Cumulative (${LADDER_UNIT_LABEL.toLowerCase()})StatusSelect or edit
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(event.amount_usd_hr))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(event.amount_usd_hr))}${escapeHtml(formatLadderAmount(formatLadderUnits(rowTotals.get(event.id)!)))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
`; table.querySelectorAll('[data-event]').forEach(button => button.addEventListener('click', () => { const event = visible.find(item => item.id === button.dataset['event']); diff --git a/frontend/tests-e2e/ladder-timeline.spec.ts b/frontend/tests-e2e/ladder-timeline.spec.ts index 8f2fa8fe..bc4167e7 100644 --- a/frontend/tests-e2e/ladder-timeline.spec.ts +++ b/frontend/tests-e2e/ladder-timeline.spec.ts @@ -36,6 +36,8 @@ for (const width of [1280, 390]) { await expect(timeline.locator('[data-summary]')).toContainText('3 of 3'); await timeline.getByRole('combobox', { name: 'Commitment term', exact: true }).selectOption('3yr'); await expect(timeline.locator('[data-summary]')).toContainText('1 of 3'); + await expect(timeline.locator('canvas')).toHaveAttribute('aria-label', /planned additions only.*\$1\.00\/hr on Jan 3, 2030 rising to \$1\.00\/hr on Jan 3, 2030/); + await expect(timeline.locator('[data-table] th', { hasText: 'Cumulative (on-demand-equivalent $/hr)' })).toBeVisible(); await timeline.getByRole('combobox', { name: 'Payment option', exact: true }).selectOption('all-upfront'); await expect(timeline.locator('[data-summary]')).toContainText('$1.00/hr in this selection'); await timeline.getByRole('button', { name: 'Clear filters' }).click(); From a315e27eac4ca9ef667ef09b3c98367667f08346 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 23:02:58 +0200 Subject: [PATCH 3/3] fix(ladder): count only scheduled, fired and completed tranches in cumulative Cancelled, failed and unrecognised-status tranches were added to the chart, aria-label summary and Cumulative column, double counting plans the backend cancels and re-plans. They stay visible in the table marked 'not counted'. The tooltip now states the unit and layers differ by point shape as well as color. A selection with no counted tranche draws no chart. Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/ladder-timeline-filters.test.ts | 13 ++++++- .../src/__tests__/ladder-timeline.test.ts | 36 ++++++++++++++++++- frontend/src/ladder-timeline-chart.ts | 17 +++++---- frontend/src/ladder-timeline-filters.ts | 24 +++++++++---- frontend/src/ladder-timeline.ts | 17 +++++---- 5 files changed, 87 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/ladder-timeline-filters.test.ts b/frontend/src/__tests__/ladder-timeline-filters.test.ts index a2fbf482..fd84d73a 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 } from '../ladder-timeline-filters'; +import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime, ladderCumulative, ladderNotCountedNote } from '../ladder-timeline-filters'; const event: LadderEvent = { id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', @@ -80,3 +80,14 @@ test('ladderCumulative carries each layer forward on shared times and keeps zero expect(result.totals).toEqual([2500000n, 3500000n]); expect([...result.rowTotals]).toEqual([['a', 2500000n], ['b', 3500000n], ['c', 3500000n]]); }); + +// FIXTURE: hand-written statuses; the gate repro was scheduled 1.0 + cancelled 5.0 + failed 7.0 showing $13.00/hr instead of $1.00/hr. +test('ladderCumulative counts only scheduled, fired and completed tranches (#698 gate)', () => { + const at = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` }); + const rows = [at('s', 'scheduled', '1.000000', 1), at('c', 'cancelled', '5.000000', 2), at('f', 'failed', '7.000000', 3), at('x', 'weird', '9.000000', 4), at('fi', 'fired', '2.000000', 5), at('d', 'completed', '3.000000', 6)]; + const result = ladderCumulative(rows, 0n); + expect(result.totals).toEqual([1000000n, 3000000n, 6000000n]); + expect([...result.rowTotals]).toEqual([['s', 1000000n], ['c', null], ['f', null], ['x', null], ['fi', 3000000n], ['d', 6000000n]]); + 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([]); +}); diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 01c46ebd..4f6a98c7 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -223,7 +223,7 @@ test('table, filters, summary, snapshot and tooltip show formatted values, not r expect(snapshot).toContain('baseline $0.00/hr, existing unavailable, target $12.50/hr'); const chartCalls = jest.mocked(Chart).mock.calls; const tooltip = chartCalls[chartCalls.length - 1]?.[1].options?.plugins?.tooltip?.callbacks?.label as (c: { datasetIndex: number; dataIndex: number }) => string; - expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Convertible RI: $0.1235/hr cumulative'); + expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Convertible RI: $0.1235/hr cumulative, on-demand-equivalent $/hr'); }); // FIXTURE: hand-written editor input. @@ -292,3 +292,37 @@ test('a missing design token fails loudly instead of painting a default color (# await initLadderTimeline(document.getElementById('timeline')!, [], false); expect(document.querySelector('[data-state]')!.textContent).toContain('Missing CSS token --cudly-primary'); }); + +// FIXTURE: hand-written tranches reproducing the gate finding (status filter cleared so all statuses are visible). +test('cancelled, failed and unknown tranches stay visible but are not counted in chart, summary or table (#698 gate)', async () => { + const row = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` }); + jest.mocked(getLadderEvents).mockResolvedValue([row('s', 'scheduled', '1.000000', 1), row('c', 'cancelled', '5.000000', 2), row('f', 'failed', '7.000000', 3), row('x', 'weird', '9.000000', 4)]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const status = document.querySelector('[data-filter="status"]')!; + status.value = ''; status.dispatchEvent(new Event('change')); + const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: { data: { y: number }[] }[] } }; + expect(instance.data.datasets.map(d => d.data.map(p => p.y))).toEqual([[1]]); + const cells = Array.from(document.querySelectorAll('#timeline tbody tr')).map(r => r.querySelectorAll('td')[5]!.textContent); + expect(cells).toEqual(['$1.00/hr', 'Not counted', 'Not counted', 'Not counted (unknown status)']); + const label = document.querySelector('canvas')!.getAttribute('aria-label')!; + expect(label).toContain('rising to $1.00/hr'); + expect(label).toContain('Counts scheduled, fired and completed tranches'); + expect(document.querySelector('#timeline caption')!.textContent).toContain('Counts scheduled, fired and completed tranches'); + const chartConfig = jest.mocked(Chart).mock.calls[0]![1] as unknown as { data: { datasets: { pointStyle: string }[] }; options: { plugins: { tooltip: { callbacks: { label: (c: { datasetIndex: number; dataIndex: number }) => string } } } } }; + expect(chartConfig.options.plugins.tooltip.callbacks.label({ datasetIndex: 0, dataIndex: 0 })).toBe('Compute Savings Plan: $1.00/hr cumulative, on-demand-equivalent $/hr'); +}); + +test('layers are told apart by point shape as well as color, and a selection with no counted tranche draws no chart (#698 gate)', async () => { + jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, id: 'a', amount_usd_hr: '1.000000', scheduled_date: '2030-01-01T00:00:00Z' }, { ...event, id: 'b', layer_type: 'ec2-instance-sp', scheduled_date: '2030-01-02T00:00:00Z' }]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const config = jest.mocked(Chart).mock.calls[0]![1] as { data: { datasets: { pointStyle: string }[] } }; + expect(new Set(config.data.datasets.map(d => d.pointStyle)).size).toBe(2); + jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, status: 'cancelled' }]); + document.body.innerHTML = '
'; + jest.mocked(Chart).mockClear(); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const status = document.querySelector('[data-filter="status"]')!; + status.value = ''; status.dispatchEvent(new Event('change')); + expect(Chart).not.toHaveBeenCalled(); + expect(document.querySelector('.ladder-timeline-chart')!.hasAttribute('hidden')).toBe(true); +}); diff --git a/frontend/src/ladder-timeline-chart.ts b/frontend/src/ladder-timeline-chart.ts index a9e6d274..d79d90f6 100644 --- a/frontend/src/ladder-timeline-chart.ts +++ b/frontend/src/ladder-timeline-chart.ts @@ -1,7 +1,7 @@ import type { ChartConfiguration } from 'chart.js'; import type { LadderEvent, LadderRun } from './api/ladder'; import { formatDate } from './utils'; -import { formatLadderAmount, formatLadderUnits, ladderAmountUnits, ladderCumulative, ladderLabel, type LadderCumulative } from './ladder-timeline-filters'; +import { LADDER_COUNTED_NOTE, formatLadderAmount, formatLadderUnits, ladderAmountUnits, ladderCumulative, ladderLabel, type LadderCumulative } from './ladder-timeline-filters'; export const LADDER_UNIT_LABEL = 'On-demand-equivalent $/hr'; @@ -27,11 +27,13 @@ export interface LadderChartModel { target: bigint | null; } -export function ladderChartModel(sorted: readonly LadderEvent[], run: LadderRun | undefined): LadderChartModel { +/** Returns undefined when no tranche is counted, so there is nothing to draw. */ +export function ladderChartModel(sorted: readonly LadderEvent[], run: LadderRun | undefined): LadderChartModel | undefined { const baseFromRun = run?.existing_usd_hr != null; const base = baseFromRun ? ladderAmountUnits(run.existing_usd_hr!) : 0n; const target = run?.target_usd_hr != null ? ladderAmountUnits(run.target_usd_hr) : null; const cumulative = ladderCumulative(sorted, base); + if (cumulative.times.length === 0) return undefined; const series: LadderChartSeries[] = []; if (baseFromRun) series.push({ label: 'Existing commitment', values: cumulative.times.map(() => base), kind: 'base' }); for (const item of cumulative.layers) series.push({ label: ladderLabel('layer_type', item.layer), layer: item.layer, values: item.units, kind: 'layer' }); @@ -40,6 +42,7 @@ export function ladderChartModel(sorted: readonly LadderEvent[], run: LadderRun } const toNumber = (units: bigint): number => Number(formatLadderUnits(units)); +const layerPointStyles = ['circle', 'rect', 'triangle', 'rectRot', 'crossRot'] as const; const day = (stamp: number): string => formatDate(new Date(stamp).toISOString().slice(0, 10)); /** Text alternative for the canvas; the purchase table carries the same figures. */ @@ -49,7 +52,7 @@ export function ladderChartSummary(model: LadderChartModel): string { const last = totals[totals.length - 1]!; const scope = model.baseFromRun ? 'including the existing commitment snapshot' : 'planned additions only'; const target = model.target === null ? '' : ` Target ${formatLadderAmount(formatLadderUnits(model.target))}.`; - return `Cumulative planned commitment, ${LADDER_UNIT_LABEL.toLowerCase()}, ${scope}: ${formatLadderAmount(formatLadderUnits(first))} on ${day(times[0]!)} rising to ${formatLadderAmount(formatLadderUnits(last))} on ${day(times[times.length - 1]!)}.${target} The purchase table below lists the same figures.`; + return `Cumulative planned commitment, ${LADDER_UNIT_LABEL.toLowerCase()}, ${scope}: ${formatLadderAmount(formatLadderUnits(first))} on ${day(times[0]!)} rising to ${formatLadderAmount(formatLadderUnits(last))} on ${day(times[times.length - 1]!)}.${target} ${LADDER_COUNTED_NOTE} The purchase table below lists the same figures.`; } export function ladderChartConfig(model: LadderChartModel, selectedStamp: number | undefined, handlers: { @@ -59,12 +62,13 @@ export function ladderChartConfig(model: LadderChartModel, selectedStamp: number return { type: 'line', data: { - datasets: model.series.map(item => { + datasets: model.series.map((item, index) => { const color = item.kind === 'base' ? token('--cudly-text-subtle') : item.kind === 'target' ? token('--cudly-text') : token(layerTokens[item.layer!] ?? '--cudly-text-muted'); return { label: item.label, stepped: 'before', borderColor: color, backgroundColor: color, fill: item.kind !== 'target', stack: item.kind === 'target' ? 'target' : 'commitment', borderDash: item.kind === 'target' ? [6, 4] : [], - pointRadius: times.map(stamp => item.kind === 'target' ? 0 : stamp === selectedStamp ? 7 : 3), + pointStyle: item.kind === 'layer' ? layerPointStyles[index % layerPointStyles.length] : 'circle', + pointRadius: times.map(stamp => item.kind === 'target' || item.kind === 'base' ? 0 : stamp === selectedStamp ? 8 : 5), data: times.map((stamp, i) => ({ x: stamp, y: toNumber(item.values[i]!) })), }; }), @@ -78,11 +82,12 @@ export function ladderChartConfig(model: LadderChartModel, selectedStamp: number plugins: { legend: { title: { display: true, text: LADDER_UNIT_LABEL }, + labels: { usePointStyle: true }, onClick: (_event, legend) => { const layer = model.series[legend.datasetIndex ?? -1]?.layer; if (layer) handlers.legendClick(layer); }, }, tooltip: { callbacks: { label: context => { const item = model.series[context.datasetIndex]; - return item ? `${item.label}: ${formatLadderAmount(formatLadderUnits(item.values[context.dataIndex]!))} cumulative` : ''; + return item ? `${item.label}: ${formatLadderAmount(formatLadderUnits(item.values[context.dataIndex]!))} cumulative, ${LADDER_UNIT_LABEL.toLowerCase()}` : ''; } } }, }, onClick: (_event, points) => { diff --git a/frontend/src/ladder-timeline-filters.ts b/frontend/src/ladder-timeline-filters.ts index 4cdd25f4..642707df 100644 --- a/frontend/src/ladder-timeline-filters.ts +++ b/frontend/src/ladder-timeline-filters.ts @@ -92,22 +92,34 @@ export function formatLadderDateTime(iso: string): string { return `${formatDate(utc.slice(0, 10))}, ${utc.slice(11, 16)} UTC`; } +/** 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.'; + +/** Note for a row that is excluded from the cumulative, or undefined when it is counted. */ +export function ladderNotCountedNote(event: LadderEvent): string | undefined { + if (ladderCountedStatuses.includes(event.status)) return undefined; + return event.status in statusLabels ? 'Not counted' : 'Not counted (unknown status)'; +} + export interface LadderCumulative { times: number[]; layers: { layer: string; units: bigint[] }[]; /** Base plus every layer, per entry of `times`. */ totals: bigint[]; - /** Running total (base plus tranches up to and including this row), keyed by event id. */ - rowTotals: Map; + /** Running total (base plus counted tranches up to and including this row), keyed by event id; null for rows that are not counted. */ + rowTotals: Map; } -/** Cumulative planned commitment per layer over `times`; `sorted` must be ordered by scheduled_date. */ +/** Cumulative planned commitment per layer over `times`; `sorted` must be ordered by scheduled_date. Uncounted statuses are skipped. */ export function ladderCumulative(sorted: readonly LadderEvent[], base: bigint): LadderCumulative { - const times = Array.from(new Set(sorted.map(event => Date.parse(event.scheduled_date)))); - const layers = Array.from(new Set(sorted.map(event => event.layer_type))).map(layer => ({ layer, units: times.map(() => 0n) })); - const rowTotals = new Map(); + const counted = sorted.filter(event => ladderNotCountedNote(event) === undefined); + const times = Array.from(new Set(counted.map(event => Date.parse(event.scheduled_date)))); + const layers = Array.from(new Set(counted.map(event => event.layer_type))).map(layer => ({ layer, units: times.map(() => 0n) })); + const rowTotals = new Map(); let running = base; for (const event of sorted) { + if (ladderNotCountedNote(event) !== undefined) { rowTotals.set(event.id, null); continue; } const amount = ladderAmountUnits(event.amount_usd_hr); running += amount; rowTotals.set(event.id, running); diff --git a/frontend/src/ladder-timeline.ts b/frontend/src/ladder-timeline.ts index 7df14252..885a6243 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, ladderTotal, ladderLabel, formatLadderAmount, formatLadderUnits, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters'; +import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotal, ladderLabel, formatLadderAmount, formatLadderUnits, ladderNotCountedNote, LADDER_COUNTED_NOTE, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters'; import { openLadderEventEditor } from './ladder-timeline-editor'; import { ladderChartConfig, ladderChartModel, ladderChartSummary, LADDER_UNIT_LABEL } from './ladder-timeline-chart'; @@ -13,7 +13,7 @@ let disposeTimeline: (() => void) | undefined; export async function initLadderTimeline(root: HTMLElement, configs: readonly LadderConfig[], globalEnabled: boolean): Promise { disposeTimeline?.(); let chart: Chart<'line'> | undefined; - let rowTotals = new Map(); + let rowTotals = new Map(); let generation = 0; let disposed = false; let events: LadderEvent[] = []; @@ -57,6 +57,10 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La openLadderEventEditor(event, run, events.filter(item => item.run_id === event.run_id), () => { void load().then(() => focusEvent(event.id)); }); } }; + const cumulativeCell = (event: LadderEvent): string => { + const note = ladderNotCountedNote(event); + return note ?? formatLadderAmount(formatLadderUnits(rowTotals.get(event.id)!)); + }; const render = (): void => { const visible = filterLadderEvents(events, filters).sort((a, b) => Date.parse(a.scheduled_date) - Date.parse(b.scheduled_date)); if (!visible.some(event => event.id === selectedID)) selectedID = ''; @@ -73,12 +77,13 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La snapshot.textContent = run ? `Original run ${formatLadderDateTime(run.started_at)}: baseline ${formatLadderAmount(run.baseline_usd_hr)}, existing ${formatLadderAmount(run.existing_usd_hr)}, target ${formatLadderAmount(run.target_usd_hr)}. These snapshots are not a coverage forecast. ${run.actions.map(action => `${action.rationale}${action.data_sources?.length ? ` Sources: ${action.data_sources.join(', ')}.` : ''}`).join(' ')}` : 'Select a run to inspect its original baseline, existing commitment and target snapshots.'; const canvas = root.querySelector('canvas')!; const chartBox = canvas.parentElement!; - if (visible.length === 0) { + const model = visible.length ? ladderChartModel(visible, run) : undefined; + if (!model) { chart?.destroy(); chart = undefined; chartBox.hidden = true; + rowTotals = new Map(); } else { chartBox.hidden = false; - const model = ladderChartModel(visible, run); const selected = visible.find(event => event.id === selectedID); const config = ladderChartConfig(model, selected && Date.parse(selected.scheduled_date), { legendClick: layer => { filters.layer_type = filters.layer_type === layer ? '' : layer; render(); }, @@ -96,9 +101,9 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La table.textContent = events.length ? 'No purchases match these filters. Clear filters to see the retained plan.' : `No planned purchase tranches. ${rationale}`; return; } - table.innerHTML = `${visible.map(event => { + table.innerHTML = `
Planned purchases with running cumulative total. The base is the selected run's existing commitment, or 0 (planned additions only) when no run is selected.
Scheduled (UTC)TypeTermPaymentBudget (${LADDER_UNIT_LABEL.toLowerCase()})Cumulative (${LADDER_UNIT_LABEL.toLowerCase()})StatusSelect or edit
${visible.map(event => { const reason = ladderEditReason(event); - return ``; + return ``; }).join('')}
Planned purchases with running cumulative total. ${LADDER_COUNTED_NOTE} The base is the selected run's existing commitment, or 0 (planned additions only) when no run is selected.
Scheduled (UTC)TypeTermPaymentBudget (${LADDER_UNIT_LABEL.toLowerCase()})Cumulative (${LADDER_UNIT_LABEL.toLowerCase()})StatusSelect or edit
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(event.amount_usd_hr))}${escapeHtml(formatLadderAmount(formatLadderUnits(rowTotals.get(event.id)!)))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(event.amount_usd_hr))}${escapeHtml(cumulativeCell(event))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
`; table.querySelectorAll('[data-event]').forEach(button => button.addEventListener('click', () => { const event = visible.find(item => item.id === button.dataset['event']);