From 0c1a4a924e9f9d8fb9fc8977bb09f9911f78ac35 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 13:23:22 +0200 Subject: [PATCH 1/4] fix(home/chart): x-axis spans selected timeframe window (QA 3.1) The Savings-over-time chart on the Home page snapped the x-axis to the single purchase date regardless of the selected timeframe (7d/30d/ 90d/All). Now the x-axis always spans [now - window, now], purchases are positioned by their actual date within that window, and empty windows render labelled axes instead of a "No purchase history" stub. Refs QA row 405, step 3.1. --- frontend/src/__tests__/dashboard.test.ts | 97 +++++++++++++++++++- frontend/src/dashboard.ts | 109 +++++++++++++---------- 2 files changed, 158 insertions(+), 48 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 4369e9159..fc1841ff1 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,101 @@ describe('Dashboard Module', () => { const span = new Date(call.end).getTime() - new Date(call.start).getTime(); expect(Math.round(span / 86400_000)).toBe(30); }); + + // QA row 405, step 3.1 — x-axis windowing behaviour. + + test('renders chart with visible canvas even when there are no data points (QA 3.1)', async () => { + // Empty window must show axes, NOT the "no data" stub. + (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(false); + expect(empty?.classList.contains('hidden')).toBe(true); + // Chart.js must still be instantiated so axes are drawn. + expect(Chart).toHaveBeenCalled(); + }); + + 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 diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index e7d453b8a..70be1f7c0 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -613,32 +613,38 @@ 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 now = new Date(); + const nowMs = now.getTime(); 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'; + // 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 = nowMs - days * 86400_000; + const interval: 'hourly' | 'daily' | 'weekly' = days <= 7 ? 'hourly' : days <= 90 ? 'daily' : 'weekly'; try { // Q5: honour the account-filter dropdown. Backend's /history/analytics @@ -646,54 +652,54 @@ export async function loadSavingsTrendChart(): Promise { // filter is single-select so we pass the only selected ID or omit to // query all accessible accounts. const accountIDs = state.getCurrentAccountIDs(); - const currentProvider = state.getCurrentProvider(); const data = await api.getSavingsAnalytics({ - start: start.toISOString(), - end: end.toISOString(), + start: new Date(windowStartMs).toISOString(), + end: now.toISOString(), interval, ...(accountIDs.length === 1 ? { account_ids: accountIDs } : {}), }); - if (!data.data_points || data.data_points.length === 0) { - if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; } - 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.'; - empty.classList.remove('hidden'); - } - attachSparkline('ytd', []); - return; - } - canvas.classList.remove('hidden'); - empty?.classList.add('hidden'); + + 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). - const cumulativeForSpark = data.data_points.map((p: SavingsDataPoint) => p.cumulative_savings || 0); - attachSparkline('ytd', cumulativeForSpark); + 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, + })); + + // Always show the canvas with axes spanning [axisMinMs, nowMs]. + // A window with no purchases renders empty axes (per QA finding 3.1) + // rather than a "no data" stub. The stub is reserved for fetch errors. + canvas.classList.remove('hidden'); + empty?.classList.add('hidden'); 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 +714,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() }, From 406f837b72cb08f1c8d3fd89dbf969103ddd0948 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 16:52:51 +0200 Subject: [PATCH 2/4] fixup: remove stray closing brace left after rebase The rebase onto origin/feat/multicloud-web-frontend (which merged the buildTrendFilterDesc removal in PR #747) left an orphaned closing brace at frontend/src/dashboard.ts:624 that TypeScript rejected. Drop the brace so the file compiles cleanly on the rebased base. --- frontend/src/dashboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 70be1f7c0..bae0fdbd1 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -623,7 +623,6 @@ export function formatTrendAxisTick(tsMs: number, intervalHint: 'hourly' | 'dail } return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } -} /** * Load the savings-over-time trend chart for the dashboard. Fetches the From 6e5e2934c682a3bccbe5358e2eacf60acf4a64f8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 22:33:33 +0200 Subject: [PATCH 3/4] fix(home/chart): align savings-trend-empty assertions + lift All-range 365d cap Finding 1 (conflicting empty-state assertions): the QA 3.1 test at line 779 asserted that a successful-but-empty response hides #savings-trend-empty (show blank axes), while the QA 2.3 tests at lines 981-995 asserted the opposite (show a friendly empty-state message). QA 2.3 is the more-recent, intentional policy: show the empty-state with account context when a filter is active, and a generic "No purchase history yet" message when no filter is active. Production code updated to implement this policy with an early-return on empty data_points; QA 3.1 test updated and annotated to match. Finding 2 (All-range 365d cap): the 'all' range was hardcoded to 365 days both in the days computation and in the API start param, silently truncating history older than a year. Changed to 3650 days (10-year sentinel that parseDateRange accepts as a valid RFC3339 start), which lets the backend return the full available history. The x-axis interval for 'all' is now 'weekly' (appropriate for a multi-year span). Finding 3 (chart/KPI filter parity): loadSavingsTrendChart previously passed account_ids only when exactly one account was selected, causing the chart to show all-accounts data while the KPI tiles above it filtered to the selected subset. Changed the guard from `=== 1` to `> 0` so any non-empty selection is forwarded. Added a regression test asserting that multi-account filters are passed through. Provider filtering is not yet forwarded (backend analytics handler has no provider param); this is documented in a comment. --- frontend/src/__tests__/dashboard.test.ts | 37 ++++++++++++++---- frontend/src/dashboard.ts | 48 +++++++++++++++++++----- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index fc1841ff1..8e3723532 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -767,19 +767,25 @@ describe('Dashboard Module', () => { }); // QA row 405, step 3.1 — x-axis windowing behaviour. - - test('renders chart with visible canvas even when there are no data points (QA 3.1)', async () => { - // Empty window must show axes, NOT the "no data" stub. + // 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(false); - expect(empty?.classList.contains('hidden')).toBe(true); - // Chart.js must still be instantiated so axes are drawn. - expect(Chart).toHaveBeenCalled(); + 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 () => { @@ -971,6 +977,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: [] }); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index bae0fdbd1..8e50095af 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -639,23 +639,30 @@ export async function loadSavingsTrendChart(): Promise { const now = new Date(); const nowMs = now.getTime(); - const days = savingsTrendRange === 'all' ? 365 : parseInt(savingsTrendRange, 10); + const isAllRange = savingsTrendRange === 'all'; + // For 'all', request the maximum history the backend holds (10 years as a + // safe sentinel — parseDateRange accepts any RFC3339 start). The 365-day + // cap was a placeholder; removing it lets the chart show full purchase + // history when the "All" button is active. + const days = isAllRange ? 3650 : 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). + // below to the earliest purchase timestamp (or now-3650d if no purchases). const windowStartMs = nowMs - days * 86400_000; const interval: 'hourly' | 'daily' | 'weekly' = days <= 7 ? 'hourly' : days <= 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 data = await api.getSavingsAnalytics({ start: new Date(windowStartMs).toISOString(), end: now.toISOString(), interval, - ...(accountIDs.length === 1 ? { account_ids: accountIDs } : {}), + ...(accountIDs.length > 0 ? { account_ids: accountIDs } : {}), }); const points = data.data_points ?? []; @@ -685,9 +692,30 @@ export async function loadSavingsTrendChart(): Promise { y: p.cumulative_savings || 0, })); - // Always show the canvas with axes spanning [axisMinMs, nowMs]. - // A window with no purchases renders empty axes (per QA finding 3.1) - // rather than a "no data" stub. The stub is reserved for fetch errors. + // 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) { + const provider = state.getCurrentProvider(); + if (accountIDs.length > 0) { + empty.textContent = `No savings history for ${accountIDs.join(', ')}.`; + } else if (provider) { + empty.textContent = `No savings history for ${provider}.`; + } else { + 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'); From 5dc5df91a02cb8ae1d0c23d45d5f34c9a84e93ed Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 22:49:43 +0200 Subject: [PATCH 4/4] fix(home/chart): drop provider name from empty state + omit start on All range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: the empty-state message showed "No savings history for " when a provider filter was active, but the analytics endpoint does not accept a provider param yet (tracked in #764), so the query always returns all-provider data. Mentioning the provider implied a scope that was never applied. Drop the provider branch; show "No purchase history yet." until #764 lands. Add inline comment and regression test. Finding 2: the All range capped history at 10 years by computing now - 3650d as the start param. The backend parseDateRange defaults a missing start to (end - 7d), so we cannot omit it — instead send the Unix epoch sentinel "1970-01-01T00:00:00Z" to get truly unbounded history. Add regression test asserting the epoch constant reaches the API. --- frontend/src/__tests__/dashboard.test.ts | 36 ++++++++++++++++++++++++ frontend/src/dashboard.ts | 36 ++++++++++++++++-------- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 8e3723532..c08d3c664 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -766,6 +766,25 @@ describe('Dashboard Module', () => { 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 @@ -1016,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 8e50095af..55d9f6364 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -640,15 +640,20 @@ export async function loadSavingsTrendChart(): Promise { const now = new Date(); const nowMs = now.getTime(); const isAllRange = savingsTrendRange === 'all'; - // For 'all', request the maximum history the backend holds (10 years as a - // safe sentinel — parseDateRange accepts any RFC3339 start). The 365-day - // cap was a placeholder; removing it lets the chart show full purchase - // history when the "All" button is active. - const days = isAllRange ? 3650 : parseInt(savingsTrendRange, 10); + // 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-3650d if no purchases). - const windowStartMs = nowMs - days * 86400_000; - const interval: 'hourly' | 'daily' | 'weekly' = days <= 7 ? 'hourly' : days <= 90 ? 'daily' : 'weekly'; + // 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 { // Always forward account_ids to the chart so its data scope matches the @@ -659,7 +664,10 @@ export async function loadSavingsTrendChart(): Promise { // provider-scoped queries (handler_analytics.go has no provider param). const accountIDs = state.getCurrentAccountIDs(); const data = await api.getSavingsAnalytics({ - start: new Date(windowStartMs).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 > 0 ? { account_ids: accountIDs } : {}), @@ -702,12 +710,16 @@ export async function loadSavingsTrendChart(): Promise { if (points.length === 0) { canvas.classList.add('hidden'); if (empty) { - const provider = state.getCurrentProvider(); 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 if (provider) { - empty.textContent = `No savings history for ${provider}.`; } 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');