diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index db91a25c..58d186c7 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -1135,7 +1135,7 @@ describe('Dashboard Module', () => { // loaded above via the jest.mock chain, so we can import directly. // eslint-disable-next-line @typescript-eslint/no-var-requires const { renderSavingsByService, computeServiceStats, computeServiceStatsFromRecs } = require('../dashboard') as { - renderSavingsByService: (recs: unknown[]) => void; + renderSavingsByService: (recs: unknown[], filterDesc?: string) => void; computeServiceStats: (dataPoints: unknown[]) => Map; computeServiceStatsFromRecs: (recs: unknown[]) => Map; }; @@ -1426,6 +1426,43 @@ describe('Dashboard Module', () => { expect(() => renderSavingsByService([rec('ec2', 100)])).not.toThrow(); }); + // Issue #867: filter-aware empty state. + test('empty state shows generic text when no filter is active', () => { + buildDOM(); + renderSavingsByService([], ''); + const empty = document.getElementById('savings-by-service-empty'); + expect(empty?.classList.contains('hidden')).toBe(false); + expect(empty?.textContent).toBe('No positive potential savings found for current recommendations.'); + }); + + test('empty state mentions filter when provider chip is active and result is empty', () => { + buildDOM(); + renderSavingsByService([], 'AWS'); + const empty = document.getElementById('savings-by-service-empty'); + expect(empty?.classList.contains('hidden')).toBe(false); + expect(empty?.textContent).toContain('AWS'); + expect(empty?.textContent).toContain('selected filter'); + }); + + test('empty state mentions filter when account chip is active and result is empty', () => { + buildDOM(); + renderSavingsByService([], 'uuid-acct-1'); + const empty = document.getElementById('savings-by-service-empty'); + expect(empty?.classList.contains('hidden')).toBe(false); + expect(empty?.textContent).toContain('uuid-acct-1'); + }); + + test('empty state text updates when filter changes between renders', () => { + buildDOM(); + // First render with data -- chart shown, empty hidden. + renderSavingsByService([rec('ec2', 100)]); + // Second render with filter-narrowed empty result. + renderSavingsByService([], 'AWS, uuid-acct-2'); + const empty = document.getElementById('savings-by-service-empty'); + expect(empty?.classList.contains('hidden')).toBe(false); + expect(empty?.textContent).toContain('AWS, uuid-acct-2'); + }); + test('loadDashboard wires range bars to recommendations, not trend data', async () => { // The range bar chart must receive the recs from getRecommendations, // not from getSavingsAnalytics. Verify by providing recs but no analytics. diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index f1e40fa8..abb0689a 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -64,6 +64,19 @@ function isHomeTabActive(): boolean { return document.getElementById('home-tab')?.classList.contains('active') === true; } +/** + * Build a short human-readable description of the active topbar filter + * for use in empty-state messages on the Home charts. Returns '' when no + * filter is active so callers can distinguish "unfiltered empty" from + * "filtered empty". Mirrors buildTrendFilterDesc used in loadSavingsTrendChart. + */ +export function buildFilterDesc(provider: string, accountIDs: readonly string[]): string { + const parts: string[] = []; + if (provider && provider.toLowerCase() !== 'all') parts.push(provider.toUpperCase()); + if (accountIDs.length > 0) parts.push(accountIDs[0] ?? ''); + return parts.join(', '); +} + /** * Setup dashboard event handlers */ @@ -154,9 +167,13 @@ export async function loadDashboard(): Promise { throw summaryResult.reason as Error; } + // Build a human-readable filter description for filter-aware empty states + // on both Home charts. Mirrors the pattern from loadSavingsTrendChart (#747). + const filterDesc = buildFilterDesc(currentProvider, currentAccountIDs); + renderDashboardSummary(summaryData!, recs); - renderSavingsChart(summaryData!.by_service || {}); - renderSavingsByService(recs); + renderSavingsChart(summaryData!.by_service || {}, filterDesc); + renderSavingsByService(recs, filterDesc); renderUpcomingPurchases(upcomingData?.purchases || []); // Load the savings-over-time widget independently -- failure shouldn't // block the rest of the dashboard (e.g. analytics not configured). @@ -323,7 +340,7 @@ function attachSparkline(key: string, values: readonly number[]): void { export const __test__ = { sparklinePoints, attachSparkline, computeServiceStats }; -function renderSavingsChart(byService: Record): void { +function renderSavingsChart(byService: Record, filterDesc = ''): void { const ctx = document.getElementById('savings-chart') as HTMLCanvasElement | null; if (!ctx) return; @@ -339,16 +356,21 @@ function renderSavingsChart(byService: Record): void { // No data → hide the canvas and render an empty-state message so the // chart doesn't render with a synthetic $0–$1 y-axis. + // When a filter is active, mention it so the user understands why the + // chart is blank (mirrors the savings-trend empty-state pattern from #747). const section = ctx.parentElement; let emptyState = section?.querySelector('.chart-empty'); if (labels.length === 0) { ctx.classList.add('hidden'); + const emptyText = filterDesc + ? `No savings data for the selected filter (${filterDesc}).` + : 'No savings data yet. Add accounts and wait for recommendations.'; if (section && !emptyState) { emptyState = document.createElement('p'); emptyState.className = 'chart-empty empty'; - emptyState.textContent = 'No savings data yet. Add accounts and wait for recommendations.'; section.appendChild(emptyState); } + if (emptyState) emptyState.textContent = emptyText; return; } // Data is back — restore the canvas and remove any stale empty state. @@ -746,7 +768,7 @@ export function computeServiceStatsFromRecs( * Empty state: when no recommendations are available, the canvas is hidden and * the empty-state paragraph is shown. */ -export function renderSavingsByService(recs: readonly LocalRecommendation[]): void { +export function renderSavingsByService(recs: readonly LocalRecommendation[], filterDesc = ''): void { const canvas = document.getElementById('savings-by-service-chart') as HTMLCanvasElement | null; const emptyEl = document.getElementById('savings-by-service-empty'); const section = document.getElementById('savings-by-service-section'); @@ -768,7 +790,14 @@ export function renderSavingsByService(recs: readonly LocalRecommendation[]): vo if (positive.length === 0) { if (heading) heading.textContent = 'Potential savings range per service'; canvas.classList.add('hidden'); - emptyEl?.classList.remove('hidden'); + 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). + emptyEl.textContent = filterDesc + ? `No recommendations for the selected filter (${filterDesc}).` + : 'No positive potential savings found for current recommendations.'; + emptyEl.classList.remove('hidden'); + } return; }