diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 4369e9159..c08d3c664 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -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', () => ({ @@ -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 @@ -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: [] }); @@ -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 diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index e7d453b8a..55d9f6364 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -613,87 +613,132 @@ async function cancelScheduledPurchase(executionId: string): Promise { } /** - * 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 { 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, @@ -708,11 +753,20 @@ export async function loadSavingsTrendChart(): Promise { 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() },