From edf1c4876f0b483907ce5a9f8b75b99a75b9da2f Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:01:11 +0000 Subject: [PATCH 1/3] feat(frontend): add a chart text-alternative helper (summary label + table) Chart canvases are role="img", so their aria-label is all a screen reader can announce, and every Home/Purchases chart uses a fixed label with no values. lib/chart-a11y.ts adds setChartSummary (a data-driven aria-label) and renderChartTable / removeChartTable, a collapsed "View as table"
listing every plotted value with column and row headers. It is inserted after an anchor the caller picks so it never sits inside a box Chart.js measures, keeps its open state across data refreshes, and builds cells with textContent only. Refs #744 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-a11y.test.ts | 72 +++++++++++++++++++++ frontend/src/lib/chart-a11y.ts | 78 +++++++++++++++++++++++ frontend/src/styles/charts.css | 48 ++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 frontend/src/__tests__/chart-a11y.test.ts create mode 100644 frontend/src/lib/chart-a11y.ts diff --git a/frontend/src/__tests__/chart-a11y.test.ts b/frontend/src/__tests__/chart-a11y.test.ts new file mode 100644 index 000000000..60b54449e --- /dev/null +++ b/frontend/src/__tests__/chart-a11y.test.ts @@ -0,0 +1,72 @@ +/** + * Issue #744: text alternatives for chart canvases. + */ +import { setChartSummary, renderChartTable, removeChartTable } from '../lib/chart-a11y'; + +describe('chart-a11y', () => { + let canvas: HTMLCanvasElement; + + beforeEach(() => { + document.body.innerHTML = '

'; + canvas = document.getElementById('c') as HTMLCanvasElement; + }); + + const data = { + caption: 'Savings', + columns: ['Service', 'Current', 'Total'], + rows: [['compute', '$70', '$210'], ['cache', '$25', '$110']], + }; + + test('setChartSummary replaces the static aria-label', () => { + setChartSummary(canvas, 'Bar chart of 2 services, $320 in total.'); + expect(canvas.getAttribute('aria-label')).toBe('Bar chart of 2 services, $320 in total.'); + }); + + test('renders a collapsed "View as table" disclosure right after the anchor', () => { + renderChartTable(canvas, 't', data); + const details = document.getElementById('t') as HTMLDetailsElement; + expect(details.tagName).toBe('DETAILS'); + expect(details.open).toBe(false); + expect(canvas.nextElementSibling).toBe(details); + expect(details.querySelector('summary')?.textContent).toBe('View as table'); + expect(details.querySelector('caption')?.textContent).toBe('Savings'); + }); + + test('uses column and row headers so screen readers can announce each cell', () => { + renderChartTable(canvas, 't', data); + const table = document.querySelector('#t table') as HTMLTableElement; + const head = Array.from(table.tHead!.querySelectorAll('th')); + expect(head.map((th) => th.textContent)).toEqual(['Service', 'Current', 'Total']); + expect(head.every((th) => th.scope === 'col')).toBe(true); + const rows = Array.from(table.tBodies[0]!.rows); + expect(rows.map((r) => Array.from(r.cells).map((c) => c.textContent))).toEqual(data.rows); + for (const r of rows) { + expect(r.cells[0]!.tagName).toBe('TH'); + expect((r.cells[0] as HTMLTableCellElement).scope).toBe('row'); + expect(r.cells[1]!.tagName).toBe('TD'); + } + }); + + test('re-rendering replaces the rows and keeps the open state', () => { + renderChartTable(canvas, 't', data); + (document.getElementById('t') as HTMLDetailsElement).open = true; + renderChartTable(canvas, 't', { ...data, rows: [['rds', '$1', '$2']] }); + expect(document.querySelectorAll('#t').length).toBe(1); + expect(document.querySelectorAll('#t table').length).toBe(1); + expect(document.querySelectorAll('#t tbody tr').length).toBe(1); + expect((document.getElementById('t') as HTMLDetailsElement).open).toBe(true); + }); + + test('cell text is never parsed as HTML', () => { + renderChartTable(canvas, 't', { ...data, rows: [['', '$1', '$2']] }); + expect(document.querySelector('#t img')).toBeNull(); + expect(document.querySelector('#t tbody th')?.textContent).toBe(''); + }); + + test('removeChartTable drops the disclosure and tolerates a missing one', () => { + renderChartTable(canvas, 't', data); + removeChartTable('t'); + expect(document.getElementById('t')).toBeNull(); + expect(() => removeChartTable('t')).not.toThrow(); + }); +}); diff --git a/frontend/src/lib/chart-a11y.ts b/frontend/src/lib/chart-a11y.ts new file mode 100644 index 000000000..ec99c4075 --- /dev/null +++ b/frontend/src/lib/chart-a11y.ts @@ -0,0 +1,78 @@ +/** + * Text alternatives for Chart.js canvases (issue #744). + * + * A exposes only its aria-label, so a fixed label leaves + * screen-reader users with no values at all. Each chart therefore gets: + * - an aria-label that summarizes what is currently plotted, and + * - a collapsed "View as table" disclosure listing every plotted value, + * which also helps sighted users who can't tell the series apart. + * + * The table is inserted after an anchor element the caller chooses (the + * canvas itself, or the canvas's dedicated sizing container) so it never sits + * inside a box Chart.js measures. DOM construction only, no innerHTML. + */ + +export interface ChartTableData { + /** Table caption; normally the chart's heading. */ + caption: string; + /** Column headers. The first column labels each row. */ + columns: readonly string[]; + /** Cell text, one array per row, already formatted for display. */ + rows: ReadonlyArray; +} + +/** Replace a canvas's accessible name with a summary of the plotted data. */ +export function setChartSummary(canvas: HTMLCanvasElement, summary: string): void { + canvas.setAttribute('aria-label', summary); +} + +/** + * Render (or re-render) the "View as table" disclosure with the given id + * immediately after `anchor`. An existing disclosure keeps its open/closed + * state across data refreshes. + */ +export function renderChartTable(anchor: Element, id: string, data: ChartTableData): void { + let details = document.getElementById(id) as HTMLDetailsElement | null; + if (!details) { + details = document.createElement('details'); + details.id = id; + details.className = 'chart-table'; + const summary = document.createElement('summary'); + summary.textContent = 'View as table'; + details.appendChild(summary); + anchor.insertAdjacentElement('afterend', details); + } + details.querySelector('.chart-table-scroll')?.remove(); + + const scroll = document.createElement('div'); + scroll.className = 'chart-table-scroll'; + const table = document.createElement('table'); + const caption = document.createElement('caption'); + caption.textContent = data.caption; + table.appendChild(caption); + + const headRow = table.createTHead().insertRow(); + for (const col of data.columns) { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = col; + headRow.appendChild(th); + } + const body = table.createTBody(); + for (const row of data.rows) { + const tr = body.insertRow(); + row.forEach((cell, i) => { + const el = document.createElement(i === 0 ? 'th' : 'td'); + if (i === 0) (el as HTMLTableCellElement).scope = 'row'; + el.textContent = cell; + tr.appendChild(el); + }); + } + scroll.appendChild(table); + details.appendChild(scroll); +} + +/** Remove a chart's table, e.g. when the chart is replaced by an empty state. */ +export function removeChartTable(id: string): void { + document.getElementById(id)?.remove(); +} diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index f63828eac..8730989a7 100644 --- a/frontend/src/styles/charts.css +++ b/frontend/src/styles/charts.css @@ -106,3 +106,51 @@ #ladder-purchase-timeline [data-table] { overflow-x: auto; } .ladder-selected { outline: 2px solid #2563eb; outline-offset: -2px; } .ladder-event-editor form { padding: 1rem; } + +/* "View as table" text alternative under each chart (#744). Collapsed by + * default; the table scrolls inside a capped box so a long series doesn't + * stretch the card. */ +.chart-table { + margin-top: var(--cudly-sp-2); + font-size: var(--cudly-fs-sm); +} + +.chart-table > summary { + cursor: pointer; + color: var(--cudly-primary); + padding: var(--cudly-sp-1) 0; +} + +.chart-table-scroll { + max-height: 320px; + overflow: auto; + margin-top: var(--cudly-sp-2); +} + +.chart-table table { + width: 100%; + border-collapse: collapse; +} + +.chart-table caption { + text-align: left; + font-weight: var(--cudly-fw-bold); + padding-bottom: var(--cudly-sp-1); +} + +.chart-table th, +.chart-table td { + padding: var(--cudly-sp-1) var(--cudly-sp-2); + border-bottom: 1px solid var(--cudly-border); + text-align: right; + white-space: nowrap; +} + +.chart-table th[scope="row"], +.chart-table thead th:first-child { + text-align: left; +} + +.chart-table th[scope="row"] { + background: none; +} From 44678ef814402616aa6f6fd1a5f05f0911a3e90d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:01:11 +0000 Subject: [PATCH 2/3] fix(frontend): give the savings charts a value summary and a data table The Home "Savings over time" and "Potential savings range per service" charts and the Purchases savings-history chart now set an aria-label that states what is plotted (count, total, largest service or latest cumulative value) and render a "View as table" disclosure with every value; the table is removed when the chart switches to its empty or error state. The per-service table lists every service, including any beyond the 10 bars drawn. The per-service chart's stacked layers also get a 2px card-colored gap on top of each segment, so adjacent layers are separated by more than their shade difference. Refs #744 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/dashboard.test.ts | 91 +++++++++++++++++++ .../src/__tests__/savings-history.test.ts | 36 ++++++++ frontend/src/dashboard.ts | 43 +++++++++ frontend/src/modules/savings-history.ts | 22 +++++ .../tests-e2e/chart-text-alternative.spec.ts | 61 +++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 frontend/tests-e2e/chart-text-alternative.spec.ts diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 9a296bec6..6d56d6f5d 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -964,6 +964,36 @@ describe('Dashboard Module', () => { (state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]); }); + // #744: summary label + table for the trend chart. + test('describes the plotted series and lists every point as a table (#744)', async () => { + (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [ + { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 }, + { timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 250 }, + ] }); + + await loadSavingsTrendChart(); + + const label = document.getElementById('savings-trend-chart')?.getAttribute('aria-label') ?? ''; + expect(label).toContain('cumulative savings over the last'); + expect(label).toContain('$250'); + expect(label).toContain('2 data points'); + const rows = document.querySelectorAll('#savings-trend-table tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[1]?.querySelector('td')?.textContent).toBe('$250'); + }); + + test('removes the trend table when there is no data to plot (#744)', async () => { + (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [ + { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 }, + ] }); + await loadSavingsTrendChart(); + expect(document.getElementById('savings-trend-table')).not.toBeNull(); + + (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] }); + await loadSavingsTrendChart(); + expect(document.getElementById('savings-trend-table')).toBeNull(); + }); + test('uses daily interval for the default 90-day range', async () => { await loadSavingsTrendChart(); @@ -1625,6 +1655,67 @@ describe('Dashboard Module', () => { // renderSavingsByService DOM behaviour tests (now driven by recommendation fixtures). describe('DOM behaviour', () => { + // #744: the role="img" canvas gets a value summary and a table. + describe('text alternative (#744)', () => { + const tableRows = (): string[][] => + Array.from(document.querySelectorAll('#savings-by-service-table tbody tr')) + .map((tr) => Array.from((tr as HTMLTableRowElement).cells).map((c) => c.textContent ?? '')); + + test('summarizes the plotted values in the canvas aria-label', () => { + buildDOM(); + renderSavingsByService( + [rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)], + { ec2: { potential_savings: 300, current_savings: 40 } }, + ); + const label = document.getElementById('savings-by-service-chart')?.getAttribute('aria-label') ?? ''; + // ec2: 40 current + 60 lowest option + 200 upside = 300; rds: 50. Total 350. + expect(label).toContain('2 services'); + expect(label).toContain('$350 in total'); + expect(label).toContain('Largest: ec2 at $300'); + }); + + test('lists every layer value per service in a "View as table" disclosure', () => { + buildDOM(); + renderSavingsByService( + [rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)], + { ec2: { potential_savings: 300, current_savings: 40 } }, + ); + expect(document.querySelector('#savings-by-service-table summary')?.textContent).toBe('View as table'); + const headers = Array.from(document.querySelectorAll('#savings-by-service-table thead th')).map((th) => th.textContent); + expect(headers).toEqual(['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total']); + expect(tableRows()).toEqual([ + ['ec2', '$40', '$60', '$200', '$300'], + ['rds', '$0', '$50', '$0', '$50'], + ]); + }); + + test('the table includes services beyond the bars drawn', () => { + buildDOM(); + const many = Array.from({ length: 12 }, (_, i) => rec(`svc-${i}`, 100 + i)); + renderSavingsByService(many); + expect(tableRows()).toHaveLength(12); + }); + + test('the table is removed when the chart switches to its empty state', () => { + buildDOM(); + renderSavingsByService([rec('ec2', 100)]); + expect(document.getElementById('savings-by-service-table')).not.toBeNull(); + renderSavingsByService([]); + expect(document.getElementById('savings-by-service-table')).toBeNull(); + }); + + test('stacked layers are separated by a 2px gap, not only by shade', () => { + buildDOM(); + renderSavingsByService([rec('ec2', 100), rec('ec2', 300, 3)], { ec2: { potential_savings: 300, current_savings: 40 } }); + const calls = (Chart as unknown as jest.Mock).mock.calls; + const datasets = (calls[calls.length - 1]?.[1] as { data: { datasets: { borderWidth: { top: number }; borderColor: string }[] } }).data.datasets; + for (const d of datasets) { + expect(d.borderWidth.top).toBe(2); + expect(d.borderColor).toBe('#ffffff'); + } + }); + }); + test('shows empty state and hides canvas when no recommendations', () => { buildDOM(); renderSavingsByService([]); diff --git a/frontend/src/__tests__/savings-history.test.ts b/frontend/src/__tests__/savings-history.test.ts index df1b1bb9f..24655b008 100644 --- a/frontend/src/__tests__/savings-history.test.ts +++ b/frontend/src/__tests__/savings-history.test.ts @@ -168,6 +168,42 @@ describe('Savings History Module', () => { expect(Chart).toHaveBeenCalled(); }); + // #744: the canvas gets a value summary and a "View as table" disclosure + // placed after (not inside) the chart's sizing container. + test('adds a summary label and a data table for the plotted points (#744)', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue({ + summary: { total_period_savings: 25, total_upfront_spent: 0, purchase_count: 2, average_savings_per_period: 12.5, peak_savings: 15 }, + data_points: [ + { timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 }, + { timestamp: '2024-01-02T00:00:00Z', total_savings: 15, cumulative_savings: 25, total_upfront: 0, purchase_count: 1 }, + ], + }); + + await loadSavingsHistory(); + + const canvas = document.getElementById('savings-history-chart'); + expect(canvas?.getAttribute('aria-label')).toContain('2 data points'); + expect(canvas?.getAttribute('aria-label')).toContain('$25.00'); + const table = document.getElementById('savings-history-table'); + expect(document.getElementById('savings-history-container')?.nextElementSibling).toBe(table); + const headers = Array.from(table?.querySelectorAll('thead th') ?? []).map((th) => th.textContent); + expect(headers).toEqual(['Date', 'Period savings (/mo)', 'Cumulative savings']); + const last = Array.from(table?.querySelectorAll('tbody tr') ?? []).pop(); + expect(Array.from(last?.querySelectorAll('td') ?? []).map((td) => td.textContent)).toEqual(['$15.00', '$25.00']); + }); + + test('removes the data table when the chart is replaced by the empty state (#744)', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ + data_points: [{ timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 }], + }); + await loadSavingsHistory(); + expect(document.getElementById('savings-history-table')).not.toBeNull(); + + (getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] }); + await loadSavingsHistory(); + expect(document.getElementById('savings-history-table')).toBeNull(); + }); + test('shows empty state when no data points', async () => { (getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 2a8e61716..2a3705056 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -14,6 +14,7 @@ import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; import { formatTrendAxisTick } from './modules/chart-utils'; +import { setChartSummary, renderChartTable, removeChartTable } from './lib/chart-a11y'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -31,6 +32,14 @@ let savingsByServiceChart: Chart | null = null; // Maximum number of services to show in the bar chart before truncating. const SAVINGS_BY_SERVICE_MAX = 10; +// #744: a 2px card-coloured gap on top of every stacked segment, so adjacent +// layers are separated by more than their shade difference. +const SEGMENT_GAP = { borderColor: '#ffffff', borderWidth: { top: 2, right: 0, bottom: 0, left: 0 } } as const; + +// Ids of the "View as table" text alternatives for the Home charts (#744). +const SAVINGS_BY_SERVICE_TABLE_ID = 'savings-by-service-table'; +const SAVINGS_TREND_TABLE_ID = 'savings-trend-table'; + // Palette for per-service bars. A service's colour is fixed by its name (see // serviceBarColor), never by its rank, so it survives data refreshes (#700). const SERVICE_BAR_COLORS = [ @@ -947,6 +956,7 @@ export function renderSavingsByService( if (positive.length === 0) { if (heading) heading.textContent = 'Potential savings range per service'; canvas.classList.add('hidden'); + removeChartTable(SAVINGS_BY_SERVICE_TABLE_ID); if (emptyEl) { // When a filter is active and excluded all results, surface it so the // user understands why the chart is blank (mirrors #747 pattern). @@ -985,6 +995,21 @@ export function renderSavingsByService( const totalSavings = positive.reduce((acc, e) => acc + e.visibleTotal, 0); + // #744: the canvas is role="img", so its label and a table are the only + // way a screen-reader user can read these values. Every service is listed, + // including any beyond the SAVINGS_BY_SERVICE_MAX bars drawn. + const top = positive[0]!; + setChartSummary(canvas, + `Stacked bar chart of monthly savings for ${positive.length} service${positive.length === 1 ? '' : 's'}, ` + + `${formatCurrency(totalSavings)} in total. Largest: ${top.svc} at ${formatCurrency(top.visibleTotal)}. ` + + 'Layers: Current / Committed, Lowest option, Upside. Values are in the table below the chart.'); + renderChartTable(canvas, SAVINGS_BY_SERVICE_TABLE_ID, { + caption: 'Potential savings range per service (monthly)', + columns: ['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total'], + rows: positive.map(e => [e.svc, formatCurrency(e.current), formatCurrency(e.lowestOption), + formatCurrency(e.upside), formatCurrency(e.visibleTotal)]), + }); + // Colour: one stable base colour per service name, then derive lighter/darker shades. const bgColors = visible.map(e => serviceBarColor(e.svc)); @@ -1013,6 +1038,7 @@ export function renderSavingsByService( data: currentData, backgroundColor: currentColors, borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 4 }, + ...SEGMENT_GAP, stack: 'savings', }, { @@ -1020,6 +1046,7 @@ export function renderSavingsByService( data: lowestOptionData, backgroundColor: lowestOptionColors, borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 }, + ...SEGMENT_GAP, stack: 'savings', }, { @@ -1027,6 +1054,7 @@ export function renderSavingsByService( data: upsideData, backgroundColor: upsideColors, borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 }, + ...SEGMENT_GAP, stack: 'savings', }, ], @@ -1198,6 +1226,7 @@ export async function loadSavingsTrendChart(): Promise { empty.classList.remove('hidden'); } if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; } + removeChartTable(SAVINGS_TREND_TABLE_ID); attachSparkline('ytd', []); return; } @@ -1206,6 +1235,19 @@ export async function loadSavingsTrendChart(): Promise { if (savingsTrendChart) savingsTrendChart.destroy(); + // #744: summarize the plotted series and list every point as text. + const rangeText = isAllRange ? 'the last year' : `the last ${days} days`; + const latest = chartData[chartData.length - 1]!; + setChartSummary(canvas, + `Line chart of cumulative savings over ${rangeText}: ${formatCurrency(latest.y)} ` + + `as of ${formatTrendAxisTick(latest.x, interval)}, ${chartData.length} data point${chartData.length === 1 ? '' : 's'}. ` + + 'Values are in the table below the chart.'); + renderChartTable(canvas, SAVINGS_TREND_TABLE_ID, { + caption: `Cumulative savings over ${rangeText}`, + columns: ['Date', 'Cumulative savings'], + rows: chartData.map(p => [formatTrendAxisTick(p.x, interval), formatCurrency(p.y)]), + }); + savingsTrendChart = new Chart(canvas, { type: 'line', data: { @@ -1260,6 +1302,7 @@ export async function loadSavingsTrendChart(): Promise { // the dashboard — hide the widget and fall back to a neutral message. console.warn('Savings trend chart unavailable:', err); if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; } + removeChartTable(SAVINGS_TREND_TABLE_ID); attachSparkline('ytd', []); canvas.classList.add('hidden'); if (empty) { diff --git a/frontend/src/modules/savings-history.ts b/frontend/src/modules/savings-history.ts index 60a4332f5..b6ed57dec 100644 --- a/frontend/src/modules/savings-history.ts +++ b/frontend/src/modules/savings-history.ts @@ -6,12 +6,15 @@ import { Chart, registerables } from 'chart.js'; import { getSavingsAnalytics, type SavingsAnalyticsResponse, type SavingsDataPoint } from '../api'; import * as state from '../state'; import { formatTrendAxisTick } from './chart-utils'; +import { setChartSummary, renderChartTable, removeChartTable } from '../lib/chart-a11y'; // Register Chart.js components Chart.register(...registerables); // Chart instance let savingsChart: Chart | null = null; +// Id of the chart's "View as table" text alternative (#744). +const SAVINGS_HISTORY_TABLE_ID = 'savings-history-table'; // Canonical unit the API returns for per-bucket and summary values. // estimated_savings in purchase_history is the monthly savings figure @@ -143,6 +146,7 @@ function showEmptyState( filterDesc: string = '', ): void { if (chartContainer) chartContainer.classList.add('hidden'); + removeChartTable(SAVINGS_HISTORY_TABLE_ID); if (emptyEl) { emptyEl.classList.remove('hidden'); // Update the copy inside the empty-state element so the message @@ -184,6 +188,7 @@ function showErrorState( message: string, ): void { if (chartContainer) chartContainer.classList.add('hidden'); + removeChartTable(SAVINGS_HISTORY_TABLE_ID); if (statsEl) statsEl.classList.add('hidden'); if (emptyEl) { emptyEl.classList.remove('hidden'); @@ -401,6 +406,23 @@ function renderSavingsChart( savingsChart.destroy(); } + // #744: a value summary for the role="img" canvas plus every point as a + // table. The table goes after the sizing container, not inside it. + const latest = dataPoints[dataPoints.length - 1]; + setChartSummary(ctx, + `Line chart of savings over time: ${dataPoints.length} data point${dataPoints.length === 1 ? '' : 's'}, ` + + `cumulative savings ${formatCurrency(latest?.cumulative_savings ?? 0)} at the latest point. ` + + 'Values are in the table below the chart.'); + renderChartTable(ctx.parentElement ?? ctx, SAVINGS_HISTORY_TABLE_ID, { + caption: 'Savings history', + columns: ['Date', `Period savings (${suffix})`, 'Cumulative savings'], + rows: periodSavingsData.map((p, i) => [ + formatTrendAxisTick(p.x, tickIntervalHint), + formatCurrency(p.y), + formatCurrency(cumulativeSavingsData[i]?.y), + ]), + }); + savingsChart = new Chart(ctx, { type: 'line', data: { diff --git a/frontend/tests-e2e/chart-text-alternative.spec.ts b/frontend/tests-e2e/chart-text-alternative.spec.ts new file mode 100644 index 000000000..3406dd40a --- /dev/null +++ b/frontend/tests-e2e/chart-text-alternative.spec.ts @@ -0,0 +1,61 @@ +/** + * Issue #744: the Home charts are , so without a text + * alternative a screen-reader user can't read any value. Each chart now has a + * data-driven aria-label and a keyboard-operable "View as table" disclosure. + */ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth, RECS } from './fixtures/recs'; + +async function openHome(page: Page): Promise { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/dashboard/upcoming', (route) => route.fulfill({ json: { purchases: [] } })); + await page.route('**/api/dashboard/summary**', (route) => + route.fulfill({ + json: { + potential_monthly_savings: 0, total_recommendations: RECS.length, active_commitments: 0, + committed_monthly: 0, current_coverage: 0, target_coverage: 80, ytd_savings: 0, by_service: {}, + }, + }), + ); + await page.route('**/api/history/analytics**', (route) => + route.fulfill({ + json: { + summary: {}, + data_points: [ + { timestamp: new Date(Date.now() - 2 * 86400_000).toISOString(), total_savings: 40, cumulative_savings: 40 }, + { timestamp: new Date(Date.now() - 86400_000).toISOString(), total_savings: 60, cumulative_savings: 100 }, + ], + }, + }), + ); + await page.goto('/home'); +} + +test('the per-service chart has a value summary and a keyboard-operable table', async ({ page }) => { + await openHome(page); + const services = new Set(RECS.map((r) => r.service)); + + const canvas = page.locator('#savings-by-service-chart'); + await expect(canvas).toHaveAttribute('aria-label', new RegExp(`for ${services.size} services`)); + + const disclosure = page.locator('#savings-by-service-table'); + const table = disclosure.locator('table'); + await expect(table).toBeHidden(); + + await disclosure.locator('summary').focus(); + await page.keyboard.press('Enter'); + await expect(table).toBeVisible(); + await expect(table.locator('tbody tr')).toHaveCount(services.size); + for (const svc of services) { + await expect(table.getByRole('rowheader', { name: svc, exact: true })).toBeVisible(); + } +}); + +test('the savings trend chart has a value summary and a table', async ({ page }) => { + await openHome(page); + await expect(page.locator('#savings-trend-chart')).toHaveAttribute('aria-label', /\$100 as of .*2 data points/); + const disclosure = page.locator('#savings-trend-table'); + await disclosure.locator('summary').click(); + await expect(disclosure.locator('tbody tr')).toHaveCount(2); +}); From 21ea7a89e86665959d11ad30d768aff9f0dadd2e Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 21:15:27 +0000 Subject: [PATCH 3/3] fix(frontend): use two points in the trend-table test after the sparse state Main's #1334 shows a "not enough history yet" message instead of a chart when the savings trend has fewer than MIN_TREND_POINTS (2) points. The #744 test that checks the trend table is removed on empty data started from a single point, which is now the sparse state with no chart and no table. It now starts from two points, and a new case checks the table is also removed when the data drops to the sparse state. Refs #744 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/dashboard.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 32e637e48..8996d8841 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -1076,10 +1076,15 @@ describe('Dashboard Module', () => { expect(rows[1]?.querySelector('td')?.textContent).toBe('$250'); }); + // Two points: one point is the sparse-data state (#1334), which draws + // no chart and so has no table. + const twoTrendPoints = [ + { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 }, + { timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 150 }, + ]; + test('removes the trend table when there is no data to plot (#744)', async () => { - (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [ - { timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 }, - ] }); + (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints }); await loadSavingsTrendChart(); expect(document.getElementById('savings-trend-table')).not.toBeNull(); @@ -1088,6 +1093,16 @@ describe('Dashboard Module', () => { expect(document.getElementById('savings-trend-table')).toBeNull(); }); + test('removes the trend table in the sparse-data state (#744, #1334)', async () => { + (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints }); + await loadSavingsTrendChart(); + expect(document.getElementById('savings-trend-table')).not.toBeNull(); + + (api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [twoTrendPoints[0]] }); + await loadSavingsTrendChart(); + expect(document.getElementById('savings-trend-table')).toBeNull(); + }); + test('uses daily interval for the default 90-day range', async () => { await loadSavingsTrendChart();