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
156 changes: 155 additions & 1 deletion frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jest.mock('../api', () => ({
// value don't need to be updated.
getRecommendations: jest.fn().mockResolvedValue([]),
}));
import { loadSavingsTrendChart, setupSavingsTrendHandlers, setupDashboardHandlers } from '../dashboard';
import { loadSavingsTrendChart, setupSavingsTrendHandlers, setupDashboardHandlers, formatTrendAxisTick } from '../dashboard';

// Mock state module
jest.mock('../state', () => ({
Expand Down Expand Up @@ -765,6 +765,126 @@ describe('Dashboard Module', () => {
const span = new Date(call.end).getTime() - new Date(call.start).getTime();
expect(Math.round(span / 86400_000)).toBe(30);
});

test('All range sends epoch sentinel as start (not a client-side 3650d ceiling)', async () => {
// Add an 'all' button and make it active.
const bAll = document.createElement('button');
bAll.className = 'trend-range';
bAll.dataset['range'] = 'all';
bAll.textContent = 'All';
document.body.appendChild(bAll);
setupSavingsTrendHandlers();
(api.getSavingsAnalytics as jest.Mock).mockClear();

bAll.click();
await new Promise(r => setTimeout(r, 0));

const call = (api.getSavingsAnalytics as jest.Mock).mock.calls[0]?.[0];
// Must send the epoch sentinel so the backend returns unbounded history.
// A computed 'now - 3650d' would silently cap accounts with older data.
expect(call.start).toBe('1970-01-01T00:00:00Z');
});

// QA row 405, step 3.1 — x-axis windowing behaviour.
// Policy (aligned with QA 2.3 tests below): a successful-but-empty
// response shows the empty-state banner, not blank axes. The original
// QA 3.1 intent was to avoid showing a broken chart widget; QA 2.3
// superseded that with an explicit "show a friendly message" policy
// (see tests 'empty-state shows filter name' and 'empty-state shows
// generic message' at the end of this describe block).

test('shows empty-state (not canvas) when there are no data points and no filter is active (QA 3.1 / QA 2.3 policy)', async () => {
// No active account or provider filter (default mock state: [] and '').
// Expect the empty-state banner with generic text, canvas hidden.
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsTrendChart();

const canvas = document.getElementById('savings-trend-chart');
const empty = document.getElementById('savings-trend-empty');
expect(canvas?.classList.contains('hidden')).toBe(true);
expect(empty?.classList.contains('hidden')).toBe(false);
expect(empty?.textContent).toContain('No purchase history yet');
});

test('x-axis min/max spans the selected window regardless of data point dates (QA 3.1)', async () => {
// Add a 7d button before wiring handlers so setupSavingsTrendHandlers
// attaches a click listener to it. Drive it to '7' for a deterministic
// window size independent of prior test state.
const b7 = document.createElement('button');
b7.className = 'trend-range';
b7.dataset['range'] = '7';
b7.textContent = '7d';
document.body.appendChild(b7);
setupSavingsTrendHandlers();

// Single purchase at the very start of the 7-day window.
const now = Date.now();
const purchaseTs = new Date(now - 6 * 86400_000).toISOString();
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({
data_points: [{ timestamp: purchaseTs, cumulative_savings: 100, total_savings: 5, total_upfront: 200, purchase_count: 1 }],
});

(Chart as unknown as jest.Mock).mockClear();
b7.click();
await new Promise(r => setTimeout(r, 0));

const chartCalls = (Chart as unknown as jest.Mock).mock.calls;
expect(chartCalls.length).toBeGreaterThan(0);
const chartCall = chartCalls[chartCalls.length - 1];
const xScale = chartCall[1].options.scales.x;
// min must be ~7 days before max; allow 60-second clock skew in tests.
expect(xScale.max - xScale.min).toBeGreaterThanOrEqual(6 * 86400_000);
expect(xScale.max - xScale.min).toBeLessThanOrEqual(8 * 86400_000);
});

test('data points use {x: timestamp_ms, y: value} so they are positioned by real date (QA 3.1)', async () => {
const purchaseTs = '2024-06-15T12:00:00Z';
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({
data_points: [{ timestamp: purchaseTs, cumulative_savings: 250, total_savings: 10, total_upfront: 500, purchase_count: 1 }],
});

await loadSavingsTrendChart();

const chartCall = (Chart as unknown as jest.Mock).mock.calls[0];
const dataset = chartCall[1].data.datasets[0];
expect(dataset.data[0]).toMatchObject({ x: new Date(purchaseTs).getTime(), y: 250 });
});

test('fetch error shows error stub and hides canvas (not the empty-axes path) (QA 3.1)', async () => {
(api.getSavingsAnalytics as jest.Mock).mockRejectedValue(new Error('503'));

await loadSavingsTrendChart();

const canvas = document.getElementById('savings-trend-chart');
const empty = document.getElementById('savings-trend-empty');
expect(canvas?.classList.contains('hidden')).toBe(true);
expect(empty?.classList.contains('hidden')).toBe(false);
});
});

describe('formatTrendAxisTick (QA 3.1)', () => {
test('formats hourly ticks with date + time', () => {
// Use a fixed UTC timestamp: 2024-03-15 14:30 UTC.
const ts = new Date('2024-03-15T14:30:00Z').getTime();
const label = formatTrendAxisTick(ts, 'hourly');
// Expect something like "Mar 15, 14:30" — locale-dependent but must contain the date.
expect(label).toMatch(/Mar\s+\d+/);
});

test('formats daily ticks with short date only', () => {
const ts = new Date('2024-03-15T00:00:00Z').getTime();
const label = formatTrendAxisTick(ts, 'daily');
expect(label).toMatch(/Mar\s+\d+/);
// No colon (no time component).
expect(label).not.toMatch(/:/);
});

test('formats weekly ticks with short date only', () => {
const ts = new Date('2024-03-15T00:00:00Z').getTime();
const label = formatTrendAxisTick(ts, 'weekly');
expect(label).not.toMatch(/:/);
});
});

// QA row 384 step 2.3: Home page Savings-over-time chart must honor the
Expand Down Expand Up @@ -876,6 +996,23 @@ describe('Dashboard Module', () => {
);
});

test('loadSavingsTrendChart forwards account_ids for multi-account filter (filter parity with KPI tiles)', async () => {
// Regression: previously the chart omitted account_ids when length > 1,
// causing the chart data to diverge from the KPI tiles above it which
// always forward all selected accounts. The fix passes account_ids
// unconditionally when any accounts are selected.
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue(['uuid-a', 'uuid-b']);
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({
data_points: [{ timestamp: new Date().toISOString(), cumulative_savings: 50, total_savings: 5, total_upfront: 100, purchase_count: 1 }],
});

await loadSavingsTrendChart();

expect(api.getSavingsAnalytics).toHaveBeenCalledWith(
expect.objectContaining({ account_ids: ['uuid-a', 'uuid-b'] })
);
});

test('empty-state shows filter name when account chip is active (QA 2.3)', async () => {
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue(['uuid-acct-3']);
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });
Expand All @@ -898,6 +1035,23 @@ describe('Dashboard Module', () => {
expect(empty?.classList.contains('hidden')).toBe(false);
expect(empty?.textContent).toContain('No purchase history yet');
});

test('empty-state does NOT mention provider even when a provider filter is active (#764)', async () => {
// The analytics endpoint ignores the provider param until #764 lands.
// Showing "No savings history for aws." would imply the query was
// scoped to that provider, which is false. Generic copy is used instead.
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(state.getCurrentProvider as jest.Mock).mockReturnValue('aws');
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsTrendChart();

const empty = document.getElementById('savings-trend-empty');
expect(empty?.classList.contains('hidden')).toBe(false);
// Provider name must not appear in the message.
expect(empty?.textContent).not.toContain('aws');
expect(empty?.textContent).toContain('No purchase history yet');
});
});

// Issue #185 invariant — clear account state BEFORE awaiting the
Expand Down
150 changes: 102 additions & 48 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,87 +613,132 @@ async function cancelScheduledPurchase(executionId: string): Promise<void> {
}

/**
* Build a short human-readable description of the active topbar filter
* for use in the Savings Trend empty-state message. Returns '' when no
* filter is active so callers can distinguish "unfiltered empty" from
* "filtered empty". Mirrors buildFilterDesc() in modules/savings-history.ts.
* Format a millisecond timestamp for the savings-trend x-axis tick label.
* Exported for unit testing.
*/
function buildTrendFilterDesc(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(', ');
export function formatTrendAxisTick(tsMs: number, intervalHint: 'hourly' | 'daily' | 'weekly'): string {
const d = new Date(tsMs);
if (intervalHint === 'hourly') {
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
}
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

/**
* Load the savings-over-time trend chart for the dashboard. Fetches the
* history analytics endpoint with the currently-selected range and
* renders a line chart of cumulative savings. Failure modes (analytics
* not configured, empty data) degrade gracefully to an empty-state note.
* renders a line chart of cumulative savings spanning the full selected
* window on the x-axis (QA row 405, step 3.1). Empty windows render
* labelled axes rather than a "no data" stub; only fetch failures use
* the error stub.
*/
export async function loadSavingsTrendChart(): Promise<void> {
const canvas = document.getElementById('savings-trend-chart') as HTMLCanvasElement | null;
const empty = document.getElementById('savings-trend-empty');
if (!canvas) return;
const end = new Date();
const days = savingsTrendRange === 'all' ? 365 : parseInt(savingsTrendRange, 10);
const start = new Date(end.getTime() - days * 86400_000);
const interval = days <= 7 ? 'hourly' : days <= 90 ? 'daily' : 'weekly';

const now = new Date();
const nowMs = now.getTime();
const isAllRange = savingsTrendRange === 'all';
// For 'all', pass the Unix epoch as the start sentinel so the backend
// returns every data point it holds. parseDateRange on the backend
// defaults a missing start to (end - 7d), so we must send an explicit
// floor rather than omitting the param — epoch is the lowest valid
// RFC3339 value and has no practical upper bound on history length.
// A client-side 3650-day ceiling would silently truncate accounts with
// purchase history older than ~10 years.
const epochStart = '1970-01-01T00:00:00Z';
const days = isAllRange ? null : parseInt(savingsTrendRange, 10);
// windowStart is the left edge of the axis; for 'all' it is overridden
// below to the earliest purchase timestamp (or now-365d if no purchases).
const windowStartMs = isAllRange ? nowMs - 365 * 86400_000 : nowMs - (days as number) * 86400_000;
const intervalDays = isAllRange ? 3650 : (days as number);
const interval: 'hourly' | 'daily' | 'weekly' = intervalDays <= 7 ? 'hourly' : intervalDays <= 90 ? 'daily' : 'weekly';

try {
// Q5: honour the account-filter dropdown. Backend's /history/analytics
// takes a single account_id (see handler_analytics.go). The dashboard
// filter is single-select so we pass the only selected ID or omit to
// query all accessible accounts.
// Always forward account_ids to the chart so its data scope matches the
// KPI tiles above it. The backend /history/analytics handler accepts a
// single `account_id`; api.getSavingsAnalytics also sets the singular
// param for single-account requests (see api/history.ts).
// provider is not forwarded: the analytics backend does not yet support
// provider-scoped queries (handler_analytics.go has no provider param).
const accountIDs = state.getCurrentAccountIDs();
const currentProvider = state.getCurrentProvider();
const data = await api.getSavingsAnalytics({
start: start.toISOString(),
end: end.toISOString(),
// For 'all': send the epoch sentinel so the backend returns unbounded
// history. Omitting start would cause parseDateRange to default to
// (end - 7d), silently clipping the chart (see handler_analytics.go).
start: isAllRange ? epochStart : new Date(windowStartMs).toISOString(),
end: now.toISOString(),
interval,
...(accountIDs.length === 1 ? { account_ids: accountIDs } : {}),
...(accountIDs.length > 0 ? { account_ids: accountIDs } : {}),
});
if (!data.data_points || data.data_points.length === 0) {
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }

const points = data.data_points ?? [];

// YTD Savings KPI tile sparkline (issue #340 T6) — uses the same
// cumulative_savings series the main chart renders. Skips silently
// when the tile isn't in the DOM (e.g. a different layout is mounted).
attachSparkline('ytd', points.map((p: SavingsDataPoint) => p.cumulative_savings || 0));

// Determine the left edge of the x-axis.
// For 'all': anchor to the earliest purchase so the line fills the
// chart rather than clustering at the right end. Fall back to now-365d
// when there are no purchases.
let axisMinMs = windowStartMs;
if (savingsTrendRange === 'all' && points.length > 0) {
const earliest = points.reduce(
(min: number, p: SavingsDataPoint) => Math.min(min, new Date(p.timestamp).getTime()),
Infinity,
);
axisMinMs = earliest;
}

// Map each data point to {x: timestamp_ms, y: cumulative_savings} so
// Chart.js positions it by its real date, not by label index.
const chartData = points.map((p: SavingsDataPoint) => ({
x: new Date(p.timestamp).getTime(),
y: p.cumulative_savings || 0,
}));

// Policy (QA 2.3, supersedes QA 3.1 empty-axis approach):
// - No data + active account filter: show empty-state with the filter
// name so the user understands why the chart is blank.
// - No data + no filter active: show a generic "No purchase history yet"
// message rather than blank axes.
// - Data present: always show the chart (hide empty-state).
// The stub is also shown on fetch errors (catch block below).
if (points.length === 0) {
canvas.classList.add('hidden');
if (empty) {
// Build a short description of the active filter so the empty-state
// copy distinguishes "no purchases exist yet" from "nothing in the
// selected scope". Mirrors modules/savings-history.ts showEmptyState().
const filterDesc = buildTrendFilterDesc(currentProvider, accountIDs);
empty.textContent = filterDesc
? `No savings data for the selected filter (${filterDesc}).`
: 'No purchase history yet — the chart will populate once you start executing plans.';
if (accountIDs.length > 0) {
// Account IDs are forwarded to the backend (see call above), so
// mentioning them in the empty-state is accurate.
empty.textContent = `No savings history for ${accountIDs.join(', ')}.`;
} else {
// Provider is intentionally NOT mentioned here: the analytics
// endpoint does not accept a provider param yet (tracked in #764),
// so the query always returns all-provider data regardless of the
// topbar provider filter. Claiming provider scope would be
// misleading — drop it until #764 lands.
empty.textContent = 'No purchase history yet.';
}
empty.classList.remove('hidden');
}
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
attachSparkline('ytd', []);
return;
}
canvas.classList.remove('hidden');
empty?.classList.add('hidden');

// YTD Savings KPI tile sparkline (issue #340 T6) — uses the same
// cumulative_savings series the main chart renders. Skips silently
// when the tile isn't in the DOM (e.g. a different layout is mounted).
const cumulativeForSpark = data.data_points.map((p: SavingsDataPoint) => p.cumulative_savings || 0);
attachSparkline('ytd', cumulativeForSpark);

if (savingsTrendChart) savingsTrendChart.destroy();
const labels = data.data_points.map((p: SavingsDataPoint) => {
const d = new Date(p.timestamp);
return interval === 'hourly'
? d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
const cumulative = data.data_points.map((p: SavingsDataPoint) => p.cumulative_savings || 0);

savingsTrendChart = new Chart(canvas, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Cumulative savings',
data: cumulative,
data: chartData,
borderColor: '#1a73e8',
backgroundColor: 'rgba(26, 115, 232, 0.1)',
fill: true,
Expand All @@ -708,11 +753,20 @@ export async function loadSavingsTrendChart(): Promise<void> {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `Cumulative savings: $${(ctx.raw as number).toLocaleString()}`,
label: (ctx) => `Cumulative savings: $${((ctx.raw as { x: number; y: number }).y).toLocaleString()}`,
},
},
},
scales: {
x: {
type: 'linear',
min: axisMinMs,
max: nowMs,
ticks: {
maxTicksLimit: 6,
callback: (value) => formatTrendAxisTick(value as number, interval),
},
},
y: {
beginAtZero: true,
ticks: { callback: (v) => '$' + (v as number).toLocaleString() },
Expand Down
Loading