diff --git a/frontend/src/__tests__/ladder-timeline-filters.test.ts b/frontend/src/__tests__/ladder-timeline-filters.test.ts index 7307bdfe..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 } 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', @@ -28,7 +28,7 @@ test('six decimal totals and UTC date boundaries stay exact', () => { test('eligibility covers status, execution, deadline, and unavailable revision API', () => { expect(ladderEditReason(event)).toBeUndefined(); - expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toContain('awaiting_approval'); + expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toContain('awaiting approval'); expect(ladderEditReason({ ...event, status: 'completed' })).toContain('completed'); expect(ladderEditReason({ ...event, execution_id: 'execution' })).toContain('execution'); expect(ladderEditReason(event, Date.parse(event.scheduled_date))).toContain('passed'); @@ -61,3 +61,33 @@ test('dates render the UTC calendar day and time regardless of browser zone', () expect(formatLadderDateTime('2030-01-02T23:30:00-05:00')).toBe('Jan 3, 2030, 04:30 UTC'); expect(formatLadderDateTime('garbage')).toBe('garbage'); }); + +test('sub-0.0001 amounts are not shown as $0.0000 and edit reasons use readable run status (follow-ups to #697)', () => { + expect(formatLadderAmount('0.000040')).toBe('<$0.0001/hr'); + expect(formatLadderAmount('0.000100')).toBe('$0.0001/hr'); + expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toBe('Run is awaiting approval.'); + expect(ladderEditReason({ ...event, status: 'cancelled' })).toBe('Tranche is cancelled.'); +}); + +// FIXTURE: hand-written tranches. +test('ladderCumulative carries each layer forward on shared times and keeps zero-amount tranches', () => { + const a = { ...event, id: 'a', amount_usd_hr: '0.500000', scheduled_date: '2030-01-01T00:00:00Z' }; + const b = { ...event, id: 'b', layer_type: 'ec2-instance-sp', amount_usd_hr: '1.000000', scheduled_date: '2030-02-01T00:00:00Z' }; + const c = { ...event, id: 'c', layer_type: 'ec2-instance-sp', amount_usd_hr: '0.000000', scheduled_date: '2030-02-01T00:00:00Z' }; + const result = ladderCumulative([a, b, c], 2000000n); + expect(result.times).toEqual([Date.parse(a.scheduled_date), Date.parse(b.scheduled_date)]); + expect(result.layers).toEqual([{ layer: 'compute-sp', units: [500000n, 500000n] }, { layer: 'ec2-instance-sp', units: [0n, 1000000n] }]); + 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 ee3b1410..4f6a98c7 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -1,4 +1,4 @@ -jest.mock('chart.js', () => ({ Chart: Object.assign(jest.fn().mockImplementation(() => ({ destroy: jest.fn() })), { register: jest.fn() }), registerables: [] })); +jest.mock('chart.js', () => ({ Chart: Object.assign(jest.fn().mockImplementation((_canvas: unknown, config: { data: unknown; options: unknown }) => ({ destroy: jest.fn(), update: jest.fn(), data: config.data, options: config.options })), { register: jest.fn() }), registerables: [] })); jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() })); jest.mock('../api/ladder', () => ({ getLadderEvents: jest.fn(), getLadderRuns: jest.fn(), amendLadderEvent: jest.fn() })); jest.mock('../permissions', () => ({ canAccess: jest.fn(() => true) })); @@ -12,8 +12,12 @@ import { Chart } from 'chart.js'; const event: LadderEvent = { id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', payment_option: 'no-upfront', status: 'scheduled', run_status: 'planned', revision: 0, amount_usd_hr: '1.000000', scheduled_date: '2030-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z' }; const run: LadderRun = { id: 'run', config_id: 'config', status: 'planned', started_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', baseline_usd_hr: null, existing_usd_hr: null, target_usd_hr: null, gap_usd_hr: null, total_hourly_commit: '1.000000', actions: [] }; +// FIXTURE: stand-ins for the design tokens in styles/base.css (jsdom loads no CSS). +const tokenColors: Record = { '--cudly-primary': '#111111', '--cudly-success': '#222222', '--cudly-warn-fg': '#333333', '--cudly-error': '#444444', '--cudly-info': '#555555', '--cudly-text': '#666666', '--cudly-text-muted': '#777777', '--cudly-text-subtle': '#888888' }; + beforeEach(() => { jest.clearAllMocks(); + Object.entries(tokenColors).forEach(([name, value]) => document.documentElement.style.setProperty(name, value)); jest.mocked(canAccess).mockReturnValue(true); document.body.innerHTML = '
'; jest.mocked(listAccountsMinimal).mockResolvedValue([{ id: 'account', name: 'Account', external_id: '123', provider: 'aws' }]); @@ -123,12 +127,14 @@ test('graph legend filters share visible counts and stable layer colors', async await initLadderTimeline(document.getElementById('timeline')!, [], false); const chartConfig = jest.mocked(Chart).mock.calls[0]?.[1]; const legendClick = chartConfig?.options?.plugins?.legend?.onClick as (...args: unknown[]) => void; - const color = chartConfig?.data.datasets[1]?.backgroundColor; - expect(color).toBe('#0891b2'); + expect(chartConfig?.data.datasets.map(d => d.backgroundColor)).toEqual(['#111111', '#222222']); legendClick({}, { datasetIndex: 1 }); expect(document.querySelector('[data-summary]')!.textContent).toContain('1 of 2'); expect(document.querySelector('[data-filter="layer_type"]')!.value).toBe('ec2-instance-sp'); - expect(jest.mocked(Chart).mock.calls[1]?.[1].data.datasets[0]?.backgroundColor).toBe(color); + expect(Chart).toHaveBeenCalledTimes(1); + const instance = jest.mocked(Chart).mock.results[0]!.value as { update: jest.Mock; data: { datasets: { backgroundColor: string }[] } }; + expect(instance.update).toHaveBeenCalledTimes(1); + expect(instance.data.datasets.map(d => d.backgroundColor)).toEqual(['#222222']); }); test('save errors retain draft and invalid dates do not reach the API', async () => { @@ -217,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('Jan 2, 2030, 23:30 UTC: $0.1235/hr (Convertible RI, Scheduled)'); + expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Convertible RI: $0.1235/hr cumulative, on-demand-equivalent $/hr'); }); // FIXTURE: hand-written editor input. @@ -233,3 +239,90 @@ test('editor header and run ceiling are formatted while the amount input keeps t expect(text).toContain('Current run total after this change: $0.1235/hr'); expect((document.getElementById('ladder-event-amount') as HTMLInputElement).value).toBe('0.123456'); }); + +// FIXTURE: hand-written tranches: two layers, one run with existing 2.0 and target 5.0 on-demand-equivalent $/hr. +const cumulativeEvents = [ + { ...event, id: 'a', amount_usd_hr: '0.500000', scheduled_date: '2030-01-01T00:00:00Z' }, + { ...event, id: 'b', layer_type: 'ec2-instance-sp', amount_usd_hr: '1.000000', scheduled_date: '2030-02-01T00:00:00Z' }, +]; + +test('chart is a stepped stacked line of cumulative commitment with a dashed target, in on-demand-equivalent units (#698)', async () => { + jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents); + jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, existing_usd_hr: '2.000000', target_usd_hr: '5.000000' }]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const runFilter = document.querySelector('[data-filter="run_id"]')!; + runFilter.value = 'run'; runFilter.dispatchEvent(new Event('change')); + const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: Record[] } }; + expect(instance.data.datasets.map(d => [d['label'], (d['data'] as { y: number }[]).map(p => p.y)])).toEqual([ + ['Existing commitment', [2, 2]], ['Compute Savings Plan', [0.5, 0.5]], ['EC2 Instance Savings Plan', [0, 1]], ['Target', [5, 5]], + ]); + expect(instance.data.datasets.every(d => d['stepped'] === 'before')).toBe(true); + expect(instance.data.datasets[3]).toMatchObject({ borderDash: [6, 4], fill: false, stack: 'target' }); + const config = jest.mocked(Chart).mock.calls[0]![1] as { type: string }; + expect(config.type).toBe('line'); + const rows = Array.from(document.querySelectorAll('#timeline tbody tr')).map(row => row.textContent ?? ''); + expect(rows[0]).toContain('$2.50/hr'); + expect(rows[1]).toContain('$3.50/hr'); + expect(document.querySelector('#timeline thead')!.textContent).toContain('Cumulative (on-demand-equivalent $/hr)'); + const label = document.querySelector('canvas')!.getAttribute('aria-label')!; + expect(label).toContain('including the existing commitment snapshot'); + expect(label).toContain('$2.50/hr on Jan 1, 2030 rising to $3.50/hr on Feb 1, 2030'); + expect(label).toContain('Target $5.00/hr'); +}); + +test('without a selected run the baseline is zero, labelled planned additions only, and no target line is drawn (#698)', async () => { + jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: { label: string }[] } }; + expect(instance.data.datasets.map(d => d.label)).toEqual(['Compute Savings Plan', 'EC2 Instance Savings Plan']); + expect(document.querySelector('canvas')!.getAttribute('aria-label')).toContain('planned additions only'); + expect(document.querySelector('#timeline tbody tr')!.textContent).toContain('$0.5000/hr'); +}); + +test('an empty selection never constructs the chart, and a later non-empty one does (#698)', async () => { + jest.mocked(getLadderEvents).mockResolvedValue([]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + expect(Chart).not.toHaveBeenCalled(); + expect(document.querySelector('.ladder-timeline-chart')!.hasAttribute('hidden')).toBe(true); +}); + +test('a missing design token fails loudly instead of painting a default color (#698)', async () => { + document.documentElement.style.removeProperty('--cudly-primary'); + jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents); + 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 new file mode 100644 index 00000000..d79d90f6 --- /dev/null +++ b/frontend/src/ladder-timeline-chart.ts @@ -0,0 +1,100 @@ +import type { ChartConfiguration } from 'chart.js'; +import type { LadderEvent, LadderRun } from './api/ladder'; +import { formatDate } from './utils'; +import { LADDER_COUNTED_NOTE, formatLadderAmount, formatLadderUnits, ladderAmountUnits, ladderCumulative, ladderLabel, type LadderCumulative } from './ladder-timeline-filters'; + +export const LADDER_UNIT_LABEL = 'On-demand-equivalent $/hr'; + +const layerTokens: Record = { + 'compute-sp': '--cudly-primary', 'ec2-instance-sp': '--cudly-success', 'convertible-ri': '--cudly-warn-fg', + 'azure-reservation': '--cudly-error', 'azure-savings-plan': '--cudly-info', +}; + +/** Resolve a design token; a missing token is a bug, not a reason to paint a default color. */ +function token(name: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (!value) throw new Error(`Missing CSS token ${name}`); + return value; +} + +export interface LadderChartSeries { label: string; layer?: string; values: bigint[]; kind: 'base' | 'layer' | 'target' } + +export interface LadderChartModel { + cumulative: LadderCumulative; + series: LadderChartSeries[]; + /** True when the base is the selected run's existing commitment snapshot; otherwise 0. */ + baseFromRun: boolean; + target: bigint | null; +} + +/** 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' }); + if (target !== null) series.push({ label: 'Target', values: cumulative.times.map(() => target), kind: 'target' }); + return { cumulative, series, baseFromRun, target }; +} + +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. */ +export function ladderChartSummary(model: LadderChartModel): string { + const { times, totals } = model.cumulative; + const first = totals[0]!; + 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} ${LADDER_COUNTED_NOTE} The purchase table below lists the same figures.`; +} + +export function ladderChartConfig(model: LadderChartModel, selectedStamp: number | undefined, handlers: { + legendClick: (layer: string) => void; pointClick: (series: LadderChartSeries, timeIndex: number) => void; +}): ChartConfiguration<'line'> { + const { times } = model.cumulative; + return { + type: 'line', + data: { + 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] : [], + 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]!) })), + }; + }), + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { type: 'linear', title: { display: true, text: 'Scheduled date (UTC)' }, ticks: { callback: value => day(Number(value)) } }, + y: { stacked: true, beginAtZero: true, title: { display: true, text: `${LADDER_UNIT_LABEL}, cumulative` } }, + }, + 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, ${LADDER_UNIT_LABEL.toLowerCase()}` : ''; + } } }, + }, + onClick: (_event, points) => { + const point = points[0]; + const item = point && model.series[point.datasetIndex]; + if (item) handlers.pointClick(item, point.index); + }, + }, + }; +} diff --git a/frontend/src/ladder-timeline-filters.ts b/frontend/src/ladder-timeline-filters.ts index 6d26a018..642707df 100644 --- a/frontend/src/ladder-timeline-filters.ts +++ b/frontend/src/ladder-timeline-filters.ts @@ -39,8 +39,8 @@ export function ladderTotal(events: readonly LadderEvent[]): string { } export function ladderEditReason(event: LadderEvent, now = Date.now()): string | undefined { - if (event.run_status !== 'planned') return `Run is ${event.run_status}.`; - if (event.status !== 'scheduled') return `Tranche is ${event.status}.`; + if (event.run_status !== 'planned') return `Run is ${runStatusLabels[event.run_status] ?? event.run_status}.`; + if (event.status !== 'scheduled') return `Tranche is ${ladderLabel('status', event.status).toLowerCase()}.`; if (event.execution_id) return 'An execution is already linked.'; if (Date.parse(event.scheduled_date) <= now) return 'The scheduled date has passed.'; if (!Number.isSafeInteger(event.revision) || event.revision < 0) return 'Editing is unavailable until the timeline API is upgraded.'; @@ -56,6 +56,11 @@ const statusLabels: Record = { scheduled: 'Scheduled', fired: 'Fired', completed: 'Completed', cancelled: 'Cancelled', failed: 'Failed', }; +const runStatusLabels: Record = { + planned: 'planned', awaiting_approval: 'awaiting approval', approved: 'approved', executing: 'executing', + completed: 'completed', failed: 'failed', cancelled: 'cancelled', expired: 'expired', +}; + /** Label for a ladder enum code. An unknown code is shown raw so it stays visible. */ export function ladderLabel(dimension: LadderDimension, value: string): string { switch (dimension) { @@ -75,6 +80,7 @@ export function formatLadderAmount(amount: string | null | undefined): string { if (amount === null || amount === undefined) return 'unavailable'; ladderAmountUnits(amount); const value = Number(amount); + if (value > 0 && value < 0.0001) return '<$0.0001/hr'; return `${formatCurrency(value, '$', value > 0 && value < 1 ? 4 : 2)}/hr`; } @@ -85,3 +91,42 @@ export function formatLadderDateTime(iso: string): string { const utc = new Date(stamp).toISOString(); 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 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. Uncounted statuses are skipped. */ +export function ladderCumulative(sorted: readonly LadderEvent[], base: bigint): LadderCumulative { + 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); + const at = times.indexOf(Date.parse(event.scheduled_date)); + const series = layers.find(item => item.layer === event.layer_type)!; + for (let i = at; i < times.length; i++) series.units[i] = series.units[i]! + amount; + } + const totals = times.map((_, i) => layers.reduce((sum, item) => sum + item.units[i]!, base)); + return { times, layers, totals, rowTotals }; +} diff --git a/frontend/src/ladder-timeline.ts b/frontend/src/ladder-timeline.ts index 4b7d8f04..885a6243 100644 --- a/frontend/src/ladder-timeline.ts +++ b/frontend/src/ladder-timeline.ts @@ -3,15 +3,17 @@ 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, 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'; Chart.register(...registerables); let disposeTimeline: (() => void) | undefined; export async function initLadderTimeline(root: HTMLElement, configs: readonly LadderConfig[], globalEnabled: boolean): Promise { disposeTimeline?.(); - let chart: Chart<'scatter'> | undefined; + let chart: Chart<'line'> | undefined; + let rowTotals = new Map(); let generation = 0; let disposed = false; let events: LadderEvent[] = []; @@ -33,7 +35,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La

Service, instance type, region and currency filters are unavailable because saved ladder tranches do not contain those fields.

-
+
`; const provider = root.querySelector('[data-provider]')!; const account = root.querySelector('[data-account]')!; @@ -55,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 = ''; @@ -69,31 +75,35 @@ 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!; + 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 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. ${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(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']); 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();