diff --git a/frontend/src/__tests__/chart-utils.test.ts b/frontend/src/__tests__/chart-utils.test.ts new file mode 100644 index 00000000..3a537c0e --- /dev/null +++ b/frontend/src/__tests__/chart-utils.test.ts @@ -0,0 +1,26 @@ +/** + * Unit tests for the shared chart helpers in modules/chart-utils.ts. + */ +import { formatSparseTrendMessage, MIN_TREND_POINTS } from '../modules/chart-utils'; + +describe('formatSparseTrendMessage (issue #188)', () => { + test('names the single value and its date, with the year', () => { + // Midday UTC so the calendar day is the same in every timezone jest runs in. + expect(formatSparseTrendMessage({ timestamp: '2026-10-20T12:00:00Z', cumulative_savings: 1.1 })) + .toBe('Not enough history yet to show a trend. So far: $1.10 in cumulative savings as of Oct 20, 2026.'); + }); + + test('formats large values with thousands separators and cents', () => { + expect(formatSparseTrendMessage({ timestamp: '2025-01-05T12:00:00Z', cumulative_savings: 12345.678 })) + .toContain('$12,345.68 in cumulative savings as of Jan 5, 2025'); + }); + + test('treats a missing value as $0.00 rather than printing NaN', () => { + expect(formatSparseTrendMessage({ timestamp: '2025-01-05T12:00:00Z', cumulative_savings: undefined as unknown as number })) + .toContain('So far: $0.00'); + }); + + test('a trend needs at least two points', () => { + expect(MIN_TREND_POINTS).toBe(2); + }); +}); diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index a08658d1..48a06f13 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -1095,7 +1095,10 @@ describe('Dashboard Module', () => { // Override the beforeEach default of empty data_points so the chart // actually renders -- the test asserts the empty-state stays hidden. (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ - data_points: [{ timestamp: new Date().toISOString(), cumulative_savings: 500, total_savings: 50, total_upfront: 0, purchase_count: 1 }], + data_points: [ + { timestamp: new Date(Date.now() - 86400_000).toISOString(), cumulative_savings: 450, total_savings: 450, total_upfront: 0, purchase_count: 1 }, + { timestamp: new Date().toISOString(), cumulative_savings: 500, total_savings: 50, total_upfront: 0, purchase_count: 1 }, + ], }); (api.getSavingsAnalytics as jest.Mock).mockClear(); @@ -1149,11 +1152,14 @@ describe('Dashboard Module', () => { document.body.appendChild(b7); setupSavingsTrendHandlers(); - // Single purchase at the very start of the 7-day window. + // Two purchases near the 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 }], + data_points: [ + { timestamp: purchaseTs, cumulative_savings: 100, total_savings: 5, total_upfront: 200, purchase_count: 1 }, + { timestamp: new Date(now - 5 * 86400_000).toISOString(), cumulative_savings: 105, total_savings: 5, total_upfront: 200, purchase_count: 1 }, + ], }); (Chart as unknown as jest.Mock).mockClear(); @@ -1172,7 +1178,10 @@ describe('Dashboard Module', () => { 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 }], + data_points: [ + { timestamp: purchaseTs, cumulative_savings: 250, total_savings: 10, total_upfront: 500, purchase_count: 1 }, + { timestamp: '2024-06-16T12:00:00Z', cumulative_savings: 260, total_savings: 10, total_upfront: 500, purchase_count: 1 }, + ], }); await loadSavingsTrendChart(); @@ -1182,6 +1191,41 @@ describe('Dashboard Module', () => { expect(dataset.data[0]).toMatchObject({ x: new Date(purchaseTs).getTime(), y: 250 }); }); + // Issue #188: one data point would paint a lone dot on a blank canvas. + test('a single data point shows the sparse-data message instead of a chart (#188)', async () => { + (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ + data_points: [{ timestamp: '2026-10-20T12:00:00Z', cumulative_savings: 1.1, total_savings: 1.1, total_upfront: 0, purchase_count: 1 }], + }); + (Chart as unknown as jest.Mock).mockClear(); + + 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); + // formatCurrency is mocked in this file; the exact copy is pinned in chart-utils.test.ts. + expect(empty?.textContent).toContain('Not enough history yet to show a trend.'); + expect(empty?.textContent).toContain('Oct 20, 2026'); + expect(Chart).not.toHaveBeenCalled(); + }); + + test('going from one data point back to two redraws the chart and hides the sparse message (#188)', async () => { + const one = { timestamp: '2026-10-20T12:00:00Z', cumulative_savings: 1.1, total_savings: 1.1, total_upfront: 0, purchase_count: 1 }; + (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [one] }); + await loadSavingsTrendChart(); + (api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ + data_points: [one, { ...one, timestamp: '2026-10-21T12:00:00Z', cumulative_savings: 2.2 }], + }); + (Chart as unknown as jest.Mock).mockClear(); + + await loadSavingsTrendChart(); + + expect(Chart).toHaveBeenCalledTimes(1); + expect(document.getElementById('savings-trend-chart')?.classList.contains('hidden')).toBe(false); + expect(document.getElementById('savings-trend-empty')?.classList.contains('hidden')).toBe(true); + }); + 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')); @@ -1199,7 +1243,10 @@ describe('Dashboard Module', () => { test('tooltip title callback returns a formatted date string, not a raw ms timestamp (QA 3.2)', 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 }], + data_points: [ + { timestamp: purchaseTs, cumulative_savings: 250, total_savings: 10, total_upfront: 500, purchase_count: 1 }, + { timestamp: '2024-06-16T12:00:00Z', cumulative_savings: 260, total_savings: 10, total_upfront: 500, purchase_count: 1 }, + ], }); await loadSavingsTrendChart(); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index f9a97db5..4186894b 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -14,7 +14,7 @@ import { openModal, closeModal } from './modal'; import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; -import { formatTrendAxisTick, formatTrendTooltipTitle } from './modules/chart-utils'; +import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils'; // Re-export for backward compatibility (tests and other callers import from dashboard.ts). export { formatTrendAxisTick } from './modules/chart-utils'; @@ -1033,8 +1033,10 @@ export function renderSavingsByService( * supersedes QA 3.1's earlier empty-axes suggestion), the canvas is hidden * and an empty-state banner is shown: a filter-aware "No savings history * for " when a provider or account chip is active, otherwise the - * generic "No purchase history yet." Fetch failures hide the canvas and - * show the "Savings history is not available yet." stub instead. + * generic "No purchase history yet." A single data point is too sparse to + * draw a trend, so it gets a "Not enough history yet" message carrying that + * point's value instead of a lone dot on a blank canvas (#188). Fetch failures + * hide the canvas and show the "Savings history is not available yet." stub. */ export async function loadSavingsTrendChart(): Promise { const canvas = document.getElementById('savings-trend-chart') as HTMLCanvasElement | null; @@ -1118,6 +1120,17 @@ export async function loadSavingsTrendChart(): Promise { if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; } return; } + // Sparse data (#188): one point would paint a lone dot on a blank canvas, + // so show "not enough history yet" with the value we have instead. + if (points.length < MIN_TREND_POINTS) { + canvas.classList.add('hidden'); + if (empty) { + empty.textContent = formatSparseTrendMessage(points[0] as SavingsDataPoint); + empty.classList.remove('hidden'); + } + if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; } + return; + } canvas.classList.remove('hidden'); empty?.classList.add('hidden'); diff --git a/frontend/src/modules/chart-utils.ts b/frontend/src/modules/chart-utils.ts index afba24e4..a28a710c 100644 --- a/frontend/src/modules/chart-utils.ts +++ b/frontend/src/modules/chart-utils.ts @@ -2,6 +2,8 @@ * Shared chart utility helpers used by multiple chart modules. */ +import { formatCurrency } from '../utils'; + /** * Format a millisecond timestamp for a savings-trend x-axis tick label. * The intervalHint drives granularity: hourly shows date+time, everything else shows date only. @@ -15,6 +17,24 @@ export function formatTrendAxisTick(tsMs: number, intervalHint: 'hourly' | 'dail return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } +/** + * Fewest data points the Home "Savings over time" chart needs before it draws + * a line (issue #188). With a single point the chart is one dot on an + * otherwise blank canvas, so the caller shows a sparse-data message instead. + */ +export const MIN_TREND_POINTS = 2; + +/** + * Sparse-data copy for the savings-trend chart (issue #188): says why there is + * no chart yet and still surfaces the one value we do have. The date uses the + * same en-US local formatting as the chart's axis, plus the year. + */ +export function formatSparseTrendMessage(point: { timestamp: string; cumulative_savings: number }): string { + const when = new Date(point.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + const amount = formatCurrency(point.cumulative_savings || 0, '$', 2); + return `Not enough history yet to show a trend. So far: ${amount} in cumulative savings as of ${when}.`; +} + /** * Format a millisecond timestamp for a savings-trend tooltip title (issue #53). * Unlike the compact axis tick, the tooltip has room for the year, which keeps diff --git a/frontend/tests-e2e/savings-trend-sparse.spec.ts b/frontend/tests-e2e/savings-trend-sparse.spec.ts new file mode 100644 index 00000000..84e97de0 --- /dev/null +++ b/frontend/tests-e2e/savings-trend-sparse.spec.ts @@ -0,0 +1,56 @@ +/** + * Home "Savings over time" sparse-data state (issue #188). + * + * With one data point the chart painted a single dot on an otherwise blank + * 300px canvas. The widget now hides the canvas and says there is not enough + * history yet, while still showing the value it has. Two or more points draw + * the chart as before. + */ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const DAY = 86400_000; + +async function homeWithPoints(page: Page, count: number): Promise { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/dashboard/summary**', route => route.fulfill({ json: { + potential_monthly_savings: 100, total_recommendations: 3, active_commitments: 1, committed_monthly: 1.1, + current_coverage: 10, target_coverage: 80, ytd_savings: 1.1, by_service: {}, + } })); + await page.route('**/api/dashboard/upcoming', route => route.fulfill({ json: { purchases: [] } })); + await page.route('**/api/history**', async route => { + const url = new URL(route.request().url()); + if (url.pathname !== '/api/history/analytics') { + await route.fulfill({ json: [] }); + return; + } + const firstDay = Math.floor(Date.now() / DAY) * DAY - 3 * DAY + 12 * 3600_000; + await route.fulfill({ json: { + start: url.searchParams.get('start'), end: url.searchParams.get('end'), interval: url.searchParams.get('interval'), + summary: { total_monthly_savings: 1.1 * count, total_annual_savings: 13.2 * count, total_purchases: count }, + data_points: Array.from({ length: count }, (_, i) => ({ + timestamp: new Date(firstDay + i * DAY).toISOString(), + total_savings: 1.1, cumulative_savings: 1.1 * (i + 1), total_upfront: 0, purchase_count: 1, + })), + } }); + }); + await page.goto('/home'); +} + +test('a single data point shows the sparse-data message instead of a blank chart', async ({ page }) => { + await homeWithPoints(page, 1); + const message = page.locator('#savings-trend-empty'); + await expect(message).toBeVisible(); + await expect(message).toContainText('Not enough history yet to show a trend. So far: $1.10 in cumulative savings as of'); + await expect(page.locator('#savings-trend-chart')).toBeHidden(); + // The message keeps the card's height so the layout below doesn't jump. + const box = await page.locator('#savings-trend-section').boundingBox(); + expect(box?.height ?? 0).toBeGreaterThan(200); +}); + +test('two data points draw the chart and hide the sparse-data message', async ({ page }) => { + await homeWithPoints(page, 2); + await expect(page.locator('#savings-trend-chart')).toBeVisible(); + await expect(page.locator('#savings-trend-empty')).toBeHidden(); +});