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
26 changes: 26 additions & 0 deletions frontend/src/__tests__/chart-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
57 changes: 52 additions & 5 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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'));

Expand All @@ -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();
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 <filter>" 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<void> {
const canvas = document.getElementById('savings-trend-chart') as HTMLCanvasElement | null;
Expand Down Expand Up @@ -1118,6 +1120,17 @@ export async function loadSavingsTrendChart(): Promise<void> {
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');

Expand Down
20 changes: 20 additions & 0 deletions frontend/src/modules/chart-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions frontend/tests-e2e/savings-trend-sparse.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
Loading