Skip to content
Open
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
60 changes: 60 additions & 0 deletions frontend/src/__tests__/savings-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,66 @@ describe('Savings History Module', () => {
(Chart as unknown as jest.Mock).mockClear();
});

describe('requested-window average', () => {
const now = new Date('2026-10-01T12:17:15.123Z');

beforeEach(() => { jest.useFakeTimers().setSystemTime(now); });
afterEach(() => { jest.useRealTimers(); });

test.each([
['24h', 'hourly', 24], ['7d', 'hourly', 168],
['30d', 'daily', 30], ['90d', 'daily', 90],
] as const)('%s includes empty intervals in the average', async (period, interval, count) => {
(document.getElementById('savings-period') as HTMLSelectElement).value = period;
for (const mode of ['sparse', 'zero-filled', 'full']) {
(getSavingsAnalytics as jest.Mock).mockImplementation(({ start, end }) => ({
start, end, interval,
summary: { total_monthly_savings: mode === 'full' ? count * 10 : 1680 },
data_points: Array.from({ length: mode === 'sparse' ? 3 : count }, (_, i) => ({
timestamp: new Date(Date.parse(start) + i * (interval === 'hourly' ? 3600000 : 86400000)).toISOString(),
total_savings: mode === 'full' ? 10 : i < 3 ? 560 : 0,
})),
}));
await loadSavingsHistory();
expect(document.getElementById('avg-hourly-savings')?.textContent)
.toBe('$' + (mode === 'full' ? 10 : 1680 / count).toFixed(2) + '/mo');
const calls = (getSavingsAnalytics as jest.Mock).mock.calls;
const request = calls[calls.length - 1]?.[0];
expect(request.end).toBe(now.toISOString());
expect(Date.parse(request.end) - Date.parse(request.start))
.toBe(count * (interval === 'hourly' ? 3600000 : 86400000));
expect(request.interval).toBe(interval);
}
});

test.each([
['hourly', '$0.01/hr'], ['monthly', '$10.00/mo'], ['yearly', '$120.00/yr'],
])('converts the window average to %s', async (unit, expected) => {
(document.getElementById('savings-period') as HTMLSelectElement).value = '7d';
(document.getElementById('savings-unit') as HTMLSelectElement).value = unit;
(getSavingsAnalytics as jest.Mock).mockImplementation(({ start, end, interval }) => ({
start, end, interval,
summary: { total_monthly_savings: 1680, total_annual_savings: 20160 },
data_points: [start, new Date(Date.parse(start) + 72 * 3600000).toISOString(), end].map(timestamp => ({
timestamp: new Date(Math.floor(Date.parse(timestamp) / 3600000) * 3600000).toISOString(),
total_savings: 560,
})),
}));
await loadSavingsHistory();
expect(document.getElementById('avg-hourly-savings')?.textContent).toBe(expected);
});

test('keeps a zero-valued populated window visible', async () => {
(getSavingsAnalytics as jest.Mock).mockResolvedValue({
summary: { total_monthly_savings: 0 },
data_points: [{ timestamp: now.toISOString(), total_savings: 0 }],
});
await loadSavingsHistory();
expect(document.getElementById('avg-hourly-savings')?.textContent).toBe('$0.00/mo');
expect(document.getElementById('savings-stats')?.classList.contains('hidden')).toBe(false);
});
});

describe('loadSavingsHistory', () => {
test('loads and renders savings data for default 90d period', async () => {
const mockData = {
Expand Down
15 changes: 9 additions & 6 deletions frontend/src/modules/savings-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function loadSavingsHistory(): Promise<void> {
if (emptyEl) emptyEl.classList.add('hidden');
if (statsEl) statsEl.classList.remove('hidden');

renderSavingsStats(data);
renderSavingsStats(data, start, end, interval);
renderSavingsChart(data.data_points, interval, getSelectedUnit(), start, end);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
Expand Down Expand Up @@ -214,10 +214,10 @@ function buildFilterDesc(provider: string, accountIDs: readonly string[]): strin
/**
* Get start/end dates and interval based on period selection
*/
function getPeriodDates(period: string): { start: Date; end: Date; interval: 'hourly' | 'daily' | 'weekly' | 'monthly' } {
function getPeriodDates(period: string): { start: Date; end: Date; interval: 'hourly' | 'daily' } {
const end = new Date();
const start = new Date();
let interval: 'hourly' | 'daily' | 'weekly' | 'monthly' = 'hourly';
const start = new Date(end);
let interval: 'hourly' | 'daily' = 'hourly';

switch (period) {
case '24h':
Expand Down Expand Up @@ -247,7 +247,7 @@ function getPeriodDates(period: string): { start: Date; end: Date; interval: 'ho
/**
* Render savings statistics
*/
function renderSavingsStats(data: SavingsAnalyticsResponse): void {
function renderSavingsStats(data: SavingsAnalyticsResponse, start: Date, end: Date, interval: 'hourly' | 'daily'): void {
const periodSavingsEl = document.getElementById('period-savings');
// The unit indicator belongs below the value, not inside the label.
// Period Savings is a cumulative dollar total over the selected date range,
Expand Down Expand Up @@ -286,7 +286,10 @@ function renderSavingsStats(data: SavingsAnalyticsResponse): void {
}
}

const avgPerPeriod = dataPoints.length > 0 ? totalSavings / dataPoints.length : 0;
// Average projected monthly additions over the whole requested window, including empty intervals.
const intervalMs = interval === 'hourly' ? 3600000 : 86400000;
const periodCount = (end.getTime() - start.getTime()) / intervalMs;
const avgPerPeriod = totalSavings / periodCount;

// Use summary if available, otherwise fall back to calculated values.
// All three values are in the API's canonical monthly unit.
Expand Down
128 changes: 128 additions & 0 deletions frontend/tests-e2e/savings-history-average.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { test, expect, type Page } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

type Mode = 'sparse' | 'zero-filled' | 'full' | 'zero' | 'empty' | 'error';

async function savingsFixture(page: Page) {
const fixture: { mode: Mode; requests: URL[] } = { mode: 'sparse', requests: [] };
await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1'
? route.continue() : route.abort());
await seedAuth(page);
await mockApi(page);
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;
}
fixture.requests.push(url);
if (fixture.mode === 'error') {
await route.fulfill({ status: 500, json: { error: 'fixture unavailable' } });
return;
}
const start = url.searchParams.get('start')!;
const end = url.searchParams.get('end')!;
const interval = url.searchParams.get('interval')!;
const intervalMs = interval === 'hourly' ? 3600000 : 86400000;
const count = (Date.parse(end) - Date.parse(start)) / intervalMs;
const indices = fixture.mode === 'sparse' ? [0, Math.floor(count / 2), Math.floor(count)]
: Array.from({ length: fixture.mode === 'full' ? count : Math.floor(count) + 1 }, (_, i) => i);
const values = indices.map(i => fixture.mode === 'full' ? 10
: fixture.mode === 'zero' ? 0 : [0, Math.floor(count / 2), Math.floor(count)].includes(i) ? 560 : 0);
const total = values.reduce((sum, value) => sum + value, 0);
await route.fulfill({ json: {
start, end, interval,
summary: { total_monthly_savings: total, total_annual_savings: total * 12, total_purchases: 3 },
data_points: fixture.mode === 'empty' ? [] : indices.map((index, i) => ({
timestamp: new Date(Math.floor(Date.parse(start) / intervalMs) * intervalMs + index * intervalMs).toISOString(),
total_savings: values[i], cumulative_savings: values.slice(0, i + 1).reduce((sum, value) => sum + value, 0),
total_upfront: 0, purchase_count: values[i] ? 1 : 0,
})),
} });
});
return fixture;
}

async function changePeriod(page: Page, period: string) {
const response = page.waitForResponse(value => value.url().includes('/history/analytics?'));
await page.locator('#savings-period').selectOption(period);
await response;
}

test.use({ timezoneId: 'UTC' });

for (const [period, count, average, totalFull] of [
['24h', 24, '$70.00/mo', '$240.00'], ['7d', 168, '$10.00/mo', '$1.68K'],
['30d', 30, '$56.00/mo', '$300.00'], ['90d', 90, '$18.67/mo', '$900.00'],
] as const) {
test(period + ' averages the requested window regardless of missing zero buckets', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
const fixture = await savingsFixture(page);
await page.clock.setFixedTime(new Date('2026-10-01T12:17:15.123Z'));
await page.goto('/purchases/history');
for (const mode of ['sparse', 'zero-filled', 'full'] as const) {
fixture.mode = mode;
await changePeriod(page, period);
await expect(page.locator('#avg-savings-label')).toHaveText('Avg Monthly Savings');
await expect(page.locator('#avg-hourly-savings')).toHaveText(mode === 'full' ? '$10.00/mo' : average);
await expect(page.locator('#period-savings')).toHaveText(mode === 'full' ? totalFull : '$1.68K');
await expect(page.locator('#peak-savings')).toHaveText(mode === 'full' ? '$10.00/mo' : '$560.00/mo');
const request = fixture.requests[fixture.requests.length - 1]!;
const ms = period === '24h' || period === '7d' ? 3600000 : 86400000;
expect(Date.parse(request.searchParams.get('end')!) - Date.parse(request.searchParams.get('start')!)).toBe(count * ms);
}
expect(errors).toEqual([]);
});
}

test('unit switches, refresh and empty/error states work on a narrow screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const fixture = await savingsFixture(page);
await page.clock.setFixedTime(new Date('2026-10-01T12:17:15Z'));
await page.goto('/purchases/history');
await changePeriod(page, '7d');
for (const [unit, label, average, total, peak] of [
['hourly', 'Hourly', '$0.01/hr', '$2.30', '$0.77/hr'],
['yearly', 'Yearly', '$120.00/yr', '$20.16K', '$6.72K/yr'],
['monthly', 'Monthly', '$10.00/mo', '$1.68K', '$560.00/mo'],
]) {
await page.locator('#savings-unit').selectOption(unit!);
await expect(page.locator('#avg-savings-label')).toHaveText('Avg ' + label + ' Savings');
await expect(page.locator('#avg-hourly-savings')).toHaveText(average!);
await expect(page.locator('#period-savings')).toHaveText(total!);
await expect(page.locator('#peak-savings')).toHaveText(peak!);
}
fixture.mode = 'zero';
await page.locator('#refresh-savings-btn').click();
await expect(page.locator('#avg-hourly-savings')).toHaveText('$0.00/mo');
await expect(page.locator('#savings-stats')).toBeVisible();
fixture.mode = 'empty';
await page.locator('#refresh-savings-btn').click();
await expect(page.locator('#savings-stats')).toBeHidden();
await expect(page.locator('#savings-history-empty')).toContainText('No savings history data');
fixture.mode = 'error';
await page.locator('#refresh-savings-btn').click();
await expect(page.locator('#savings-history-empty')).toContainText('Failed to load savings history');
await expect(page.locator('#savings-stats')).toBeHidden();
});

test.describe('local calendar selections across DST', () => {
test.use({ timezoneId: 'Europe/Berlin' });
for (const [now, period, hours] of [
['2026-03-29T12:17:15Z', '24h', 23], ['2026-10-25T12:17:15Z', '24h', 25],
['2026-03-31T12:17:15Z', '7d', 167], ['2026-10-27T12:17:15Z', '7d', 169],
['2026-03-31T12:17:15Z', '30d', 719], ['2026-10-27T12:17:15Z', '30d', 721],
] as const) {
test(now + ' ' + period + ' uses the actual elapsed interval count', async ({ page }) => {
const fixture = await savingsFixture(page);
await page.clock.setFixedTime(new Date(now));
await page.goto('/purchases/history');
await changePeriod(page, period);
const count = period === '30d' ? hours / 24 : hours;
await expect(page.locator('#avg-hourly-savings')).toHaveText('$' + (1680 / count).toFixed(2) + '/mo');
const request = fixture.requests[fixture.requests.length - 1]!;
expect(Date.parse(request.searchParams.get('end')!) - Date.parse(request.searchParams.get('start')!)).toBe(hours * 3600000);
});
}
});
Loading