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
53 changes: 49 additions & 4 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,23 +872,40 @@ 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 () => {
// QA 3.1: 'all' range must NOT send the epoch sentinel (1970-01-01) as
// the start param. The backend caps the date range at 366 days and returns
// HTTP 400 for a 1970 start, which the catch block rendered as an empty-
// state error. The fix uses a rolling ~365-day window that fits the cap.
test('All range sends a start within ~365 days of now, not the epoch sentinel (QA 3.1)', 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();
// 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 }],
});
(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');
// Must NOT be the epoch sentinel -- that causes HTTP 400 from the backend.
expect(call.start).not.toBe('1970-01-01T00:00:00Z');
// Start must be within ~365 days of now (within 366 days to fit the cap).
const startMs = new Date(call.start as string).getTime();
const nowMs = Date.now();
const ageMs = nowMs - startMs;
expect(ageMs).toBeGreaterThan(0);
expect(ageMs).toBeLessThanOrEqual(366 * 86400_000 + 5_000); // 5s clock tolerance
// The chart must render (not show empty-state) when the API returns data.
const empty = document.getElementById('savings-trend-empty');
expect(empty?.classList.contains('hidden')).toBe(true);
});

// QA row 405, step 3.1 — x-axis windowing behaviour.
Expand Down Expand Up @@ -967,6 +984,34 @@ describe('Dashboard Module', () => {
expect(canvas?.classList.contains('hidden')).toBe(true);
expect(empty?.classList.contains('hidden')).toBe(false);
});

// QA 3.2: tooltip title callback must return a formatted date string, not
// the raw 13-digit millisecond timestamp that Chart.js renders by default
// when no title callback is configured.
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 }],
});

await loadSavingsTrendChart();

const chartCall = (Chart as unknown as jest.Mock).mock.calls[0];
const titleCb = chartCall[1].options.plugins.tooltip.callbacks.title as
(items: Array<{ raw: { x: number; y: number } }>) => string;

const xMs = new Date(purchaseTs).getTime();
const result = titleCb([{ raw: { x: xMs, y: 250 } }]);

// Must be a non-empty string.
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
// Must NOT be the raw numeric millisecond value.
expect(result).not.toBe(String(xMs));
expect(result).not.toMatch(/^\d{13}$/);
// Must contain recognizable date text (month abbreviation).
expect(result).toMatch(/Jun\s+\d+/);
});
});

describe('formatTrendAxisTick (QA 3.1)', () => {
Expand Down
43 changes: 23 additions & 20 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,11 +1003,14 @@ export function formatTrendAxisTick(tsMs: number, intervalHint: 'hourly' | 'dail

/**
* 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 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.
* history analytics endpoint with the currently-selected range and renders
* a line chart of cumulative savings spanning the full selected window on
* the x-axis (QA row 405, step 3.1). When the response is empty (QA 2.3
* 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.
*/
export async function loadSavingsTrendChart(): Promise<void> {
const canvas = document.getElementById('savings-trend-chart') as HTMLCanvasElement | null;
Expand All @@ -1017,19 +1020,15 @@ export async function loadSavingsTrendChart(): Promise<void> {
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).
// For 'all', use a rolling ~365-day window — the maximum history the
// analytics API allows (~366-day cap in handler_analytics.go). Sending a
// 1970 epoch sentinel caused HTTP 400 "date range too large" which the
// catch block rendered as an empty-state error (QA 3.1). The axisMinMs
// anchor-to-earliest-point logic below still applies so sparse data fills
// the full chart width correctly.
const windowStartMs = isAllRange ? nowMs - 365 * 86400_000 : nowMs - (days as number) * 86400_000;
const intervalDays = isAllRange ? 3650 : (days as number);
const intervalDays = isAllRange ? 365 : (days as number);
const interval: 'hourly' | 'daily' | 'weekly' = intervalDays <= 7 ? 'hourly' : intervalDays <= 90 ? 'daily' : 'weekly';

try {
Expand All @@ -1043,10 +1042,7 @@ export async function loadSavingsTrendChart(): Promise<void> {
const accountIDs = state.getCurrentAccountIDs();
const provider = state.getCurrentProvider();
const data = await api.getSavingsAnalytics({
// 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(),
start: new Date(windowStartMs).toISOString(),
end: now.toISOString(),
interval,
...(provider ? { provider } : {}),
Expand Down Expand Up @@ -1128,6 +1124,13 @@ export async function loadSavingsTrendChart(): Promise<void> {
legend: { display: false },
tooltip: {
callbacks: {
// Format the x timestamp as a human-readable date so the tooltip
// header shows a date string rather than the raw 13-digit
// millisecond value (QA 3.2).
title: (items) => {
const raw = items[0]?.raw as { x: number; y: number } | undefined;
return raw?.x != null ? formatTrendAxisTick(raw.x, interval) : '';
},
label: (ctx) => `Cumulative savings: $${((ctx.raw as { x: number; y: number }).y).toLocaleString()}`,
},
},
Expand Down
Loading