Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { min: number; max: number; sum: number; count: number; samples: number[] }>;
computeServiceStatsFromRecs: (recs: unknown[]) => Map<string, { min: number; max: number; sum: number; count: number; samples: number[]; minLabel?: string; maxLabel?: string }>;
};
Expand Down Expand Up @@ -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.
Expand Down
41 changes: 35 additions & 6 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -154,9 +167,13 @@ export async function loadDashboard(): Promise<void> {
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).
Expand Down Expand Up @@ -323,7 +340,7 @@ function attachSparkline(key: string, values: readonly number[]): void {

export const __test__ = { sparklinePoints, attachSparkline, computeServiceStats };

function renderSavingsChart(byService: Record<string, ServiceSavings>): void {
function renderSavingsChart(byService: Record<string, ServiceSavings>, filterDesc = ''): void {
const ctx = document.getElementById('savings-chart') as HTMLCanvasElement | null;
if (!ctx) return;

Expand All @@ -339,16 +356,21 @@ function renderSavingsChart(byService: Record<string, ServiceSavings>): 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<HTMLParagraphElement>('.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.
Expand Down Expand Up @@ -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');
Expand All @@ -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;
}

Expand Down
Loading