From dcb4c6a8119d932d880d0812ab5a75a6fc7ed905 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:22:04 +0000 Subject: [PATCH] feat(frontend): show a sparse-data state on the Home savings-over-time chart With a single data point (for example one ~$1 purchase) the 90-day chart painted one dot in the corner of an otherwise blank 300px canvas, which reads as broken rather than "just started". When the analytics response has fewer than two points, hide the canvas and reuse the existing #savings-trend-empty slot for "Not enough history yet to show a trend. So far: $X in cumulative savings as of ." so the one value we have is still visible. Two or more points draw the chart exactly as before, keeping the full-window x-axis that QA 3.1 asked for, so the range is not auto-fitted. The threshold and message live in modules/chart-utils.ts to keep dashboard.ts from growing. Adds jest coverage for the message and the state transitions, and a Playwright spec for the rendered state. Refs #188 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/chart-utils.test.ts | 26 +++++++++ frontend/src/__tests__/dashboard.test.ts | 57 +++++++++++++++++-- frontend/src/dashboard.ts | 19 ++++++- frontend/src/modules/chart-utils.ts | 20 +++++++ .../tests-e2e/savings-trend-sparse.spec.ts | 56 ++++++++++++++++++ 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 frontend/src/__tests__/chart-utils.test.ts create mode 100644 frontend/tests-e2e/savings-trend-sparse.spec.ts diff --git a/frontend/src/__tests__/chart-utils.test.ts b/frontend/src/__tests__/chart-utils.test.ts new file mode 100644 index 000000000..3a537c0e6 --- /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 9a296bec6..ec5f67573 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -1002,7 +1002,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(); @@ -1056,11 +1059,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(); @@ -1079,7 +1085,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(); @@ -1089,6 +1098,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')); @@ -1106,7 +1150,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 2a8e61716..7d61dc218 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -13,7 +13,7 @@ import { confirmDialog } from './confirmDialog'; import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; -import { formatTrendAxisTick } from './modules/chart-utils'; +import { formatTrendAxisTick, 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'; @@ -1111,8 +1111,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; @@ -1201,6 +1203,17 @@ export async function loadSavingsTrendChart(): Promise { attachSparkline('ytd', []); 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 ab11e3fcc..936c18696 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. @@ -14,3 +16,21 @@ 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}.`; +} 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 000000000..84e97de0a --- /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(); +});