From bdd09d1336d712914bac04b6b37ae5b0a51657d1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 1 Oct 2026 21:02:02 +0200 Subject: [PATCH] fix(frontend): average savings over the requested window Count elapsed hourly or daily periods, including periods without purchases, while preserving projected monthly savings units. Verify sparse and zero-filled windows, unit switches, partial endpoints, and daylight saving transitions through the rendered production bundle. --- .../src/__tests__/savings-history.test.ts | 60 ++++++++ frontend/src/modules/savings-history.ts | 15 +- .../tests-e2e/savings-history-average.spec.ts | 128 ++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 frontend/tests-e2e/savings-history-average.spec.ts diff --git a/frontend/src/__tests__/savings-history.test.ts b/frontend/src/__tests__/savings-history.test.ts index a24a8c53..df1b1bb9 100644 --- a/frontend/src/__tests__/savings-history.test.ts +++ b/frontend/src/__tests__/savings-history.test.ts @@ -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 = { diff --git a/frontend/src/modules/savings-history.ts b/frontend/src/modules/savings-history.ts index e75316c4..60a4332f 100644 --- a/frontend/src/modules/savings-history.ts +++ b/frontend/src/modules/savings-history.ts @@ -118,7 +118,7 @@ export async function loadSavingsHistory(): Promise { 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'; @@ -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': @@ -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, @@ -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. diff --git a/frontend/tests-e2e/savings-history-average.spec.ts b/frontend/tests-e2e/savings-history-average.spec.ts new file mode 100644 index 00000000..815de807 --- /dev/null +++ b/frontend/tests-e2e/savings-history-average.spec.ts @@ -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); + }); + } +});