diff --git a/frontend/src/__tests__/savings-history.test.ts b/frontend/src/__tests__/savings-history.test.ts index e952f0353..3d195c76e 100644 --- a/frontend/src/__tests__/savings-history.test.ts +++ b/frontend/src/__tests__/savings-history.test.ts @@ -36,7 +36,7 @@ jest.mock('../state', () => ({ })); // Now import after mocking -import { loadSavingsHistory, initSavingsHistory, savingsChart } from '../modules/savings-history'; +import { loadSavingsHistory, initSavingsHistory, savingsChart, convertFromMonthly, unitSuffix, unitLabel } from '../modules/savings-history'; import { getSavingsAnalytics } from '../api'; import * as state from '../state'; import { Chart } from 'chart.js'; @@ -51,6 +51,11 @@ describe('Savings History Module', () => { +
@@ -61,8 +66,9 @@ describe('Savings History Module', () => {
$0 - $0/hr - $0/hr +

Avg Monthly Savings

+ $0/mo + $0/mo
`; @@ -226,8 +232,8 @@ describe('Savings History Module', () => { const peakSavingsEl = document.getElementById('peak-savings'); expect(periodSavingsEl?.textContent).toContain('$1.50K'); - expect(avgSavingsEl?.textContent).toContain('$75.00/hr'); - expect(peakSavingsEl?.textContent).toContain('$200.00/hr'); + expect(avgSavingsEl?.textContent).toContain('$75.00/mo'); + expect(peakSavingsEl?.textContent).toContain('$200.00/mo'); }); test('calculates stats from data points when summary missing', async () => { @@ -799,7 +805,7 @@ describe('Savings History Module', () => { expect(y1AxisCallback('500')).toBe('$500'); }); - test('tooltip label callback formats period savings with /hr suffix', async () => { + test('tooltip label callback formats period savings with unit suffix (default monthly)', async () => { const mockData = { data_points: [ { timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 100, purchase_count: 1 } @@ -818,10 +824,10 @@ describe('Savings History Module', () => { datasetIndex: 0, dataset: { label: 'Period Savings' } }; - expect(tooltipLabelCallback(periodContext)).toBe('Period Savings: $25.5678/hr'); + expect(tooltipLabelCallback(periodContext)).toBe('Period Savings: $25.5678/mo'); }); - test('tooltip label callback formats cumulative savings without /hr suffix', async () => { + test('tooltip label callback formats cumulative savings without unit suffix', async () => { const mockData = { data_points: [ { timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 100, purchase_count: 1 } @@ -862,7 +868,7 @@ describe('Savings History Module', () => { datasetIndex: 0, dataset: { label: 'Period Savings' } }; - expect(tooltipLabelCallback(nullContext)).toBe('Period Savings: $0.0000/hr'); + expect(tooltipLabelCallback(nullContext)).toBe('Period Savings: $0.0000/mo'); const undefinedContext = { raw: undefined, @@ -957,7 +963,7 @@ describe('Savings History Module', () => { const peakSavingsEl = document.getElementById('peak-savings'); // Peak should be 150 - expect(peakSavingsEl?.textContent).toBe('$150.00/hr'); + expect(peakSavingsEl?.textContent).toBe('$150.00/mo'); }); test('destroys chart when showing empty state', async () => { @@ -1138,4 +1144,167 @@ describe('Savings History Module', () => { expect(heading?.textContent).not.toMatch(/all,/i); }); }); + + // Issue #750: unit conversion helpers + dropdown behavior + describe('unit conversion helpers', () => { + describe('convertFromMonthly', () => { + test('monthly -> monthly is identity', () => { + expect(convertFromMonthly(100, 'monthly')).toBe(100); + expect(convertFromMonthly(0, 'monthly')).toBe(0); + expect(convertFromMonthly(1.15, 'monthly')).toBe(1.15); + }); + + test('monthly -> hourly divides by 730', () => { + expect(convertFromMonthly(730, 'hourly')).toBe(1); + expect(convertFromMonthly(1.15, 'hourly')).toBeCloseTo(1.15 / 730, 6); + }); + + test('monthly -> yearly multiplies by 12', () => { + expect(convertFromMonthly(100, 'yearly')).toBe(1200); + expect(convertFromMonthly(1.15, 'yearly')).toBeCloseTo(1.15 * 12, 6); + }); + }); + + describe('unitSuffix', () => { + test('returns correct suffix for each unit', () => { + expect(unitSuffix('hourly')).toBe('/hr'); + expect(unitSuffix('monthly')).toBe('/mo'); + expect(unitSuffix('yearly')).toBe('/yr'); + }); + }); + + describe('unitLabel', () => { + test('returns correct label for each unit', () => { + expect(unitLabel('hourly')).toBe('Hourly'); + expect(unitLabel('monthly')).toBe('Monthly'); + expect(unitLabel('yearly')).toBe('Yearly'); + }); + }); + }); + + // Issue #750: unit dropdown wires + KPI display per unit + describe('unit dropdown (issue #750)', () => { + const mockData = { + summary: { + total_period_savings: 730, // $730/mo cumulative total + average_savings_per_period: 730, // $730/mo avg per bucket + peak_savings: 730 // $730/mo peak bucket + }, + data_points: [ + { timestamp: '2024-01-01T00:00:00Z', total_savings: 730, cumulative_savings: 730, total_upfront: 100, purchase_count: 1 } + ] + }; + + test('default unit is monthly -- avg and peak show /mo suffix', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + await loadSavingsHistory(); + + const avgEl = document.getElementById('avg-hourly-savings'); + const peakEl = document.getElementById('peak-savings'); + expect(avgEl?.textContent).toContain('/mo'); + expect(peakEl?.textContent).toContain('/mo'); + }); + + test('hourly unit -- avg and peak show /hr suffix and value is monthly/730', async () => { + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + await loadSavingsHistory(); + + const avgEl = document.getElementById('avg-hourly-savings'); + expect(avgEl?.textContent).toContain('/hr'); + // 730 / 730 = $1.00/hr + expect(avgEl?.textContent).toContain('$1.00'); + }); + + test('yearly unit -- avg and peak show /yr suffix and value is monthly*12', async () => { + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'yearly'; + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + await loadSavingsHistory(); + + const avgEl = document.getElementById('avg-hourly-savings'); + expect(avgEl?.textContent).toContain('/yr'); + // 730 * 12 = $8760.00 -> formatted as $8.76K + expect(avgEl?.textContent).toContain('$8.76K'); + }); + + test('avg-savings-label heading changes to reflect selected unit', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + + // Monthly (default) + await loadSavingsHistory(); + expect(document.getElementById('avg-savings-label')?.textContent).toBe('Avg Monthly Savings'); + + // Switch to hourly + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + await loadSavingsHistory(); + expect(document.getElementById('avg-savings-label')?.textContent).toBe('Avg Hourly Savings'); + + // Switch to yearly + unitSelect.value = 'yearly'; + await loadSavingsHistory(); + expect(document.getElementById('avg-savings-label')?.textContent).toBe('Avg Yearly Savings'); + }); + + test('unit dropdown change triggers loadSavingsHistory via initSavingsHistory', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] }); + initSavingsHistory(); + + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + unitSelect.dispatchEvent(new Event('change')); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(getSavingsAnalytics).toHaveBeenCalled(); + }); + + test('initSavingsHistory called twice does not stack duplicate unit-change handlers', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] }); + initSavingsHistory(); + initSavingsHistory(); + + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + (getSavingsAnalytics as jest.Mock).mockClear(); + unitSelect.dispatchEvent(new Event('change')); + + await new Promise(resolve => setTimeout(resolve, 50)); + // Should fire exactly once (no stacked duplicate listeners) + expect(getSavingsAnalytics).toHaveBeenCalledTimes(1); + }); + + test('period savings (cumulative total) also converts with unit toggle', async () => { + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + + // Monthly + await loadSavingsHistory(); + const periodElMonthly = document.getElementById('period-savings'); + expect(periodElMonthly?.textContent).toBe('$730.00'); + + // Hourly: 730 / 730 = 1.00 + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + await loadSavingsHistory(); + expect(document.getElementById('period-savings')?.textContent).toBe('$1.00'); + + // Yearly: 730 * 12 = 8760 -> $8.76K + unitSelect.value = 'yearly'; + await loadSavingsHistory(); + expect(document.getElementById('period-savings')?.textContent).toBe('$8.76K'); + }); + + test('chart tooltip uses selected unit suffix for period savings dataset', async () => { + const unitSelect = document.getElementById('savings-unit') as HTMLSelectElement; + unitSelect.value = 'hourly'; + (getSavingsAnalytics as jest.Mock).mockResolvedValue(mockData); + await loadSavingsHistory(); + + const chartCall = (Chart as unknown as jest.Mock).mock.calls[0]; + const tooltipCb = chartCall[1].options.plugins.tooltip.callbacks.label; + const ctx = { raw: 1.0, datasetIndex: 0, dataset: { label: 'Period Savings' } }; + expect(tooltipCb(ctx)).toContain('/hr'); + }); + }); }); diff --git a/frontend/src/index.html b/frontend/src/index.html index 59813a16c..668a7446d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -161,6 +161,13 @@

Savings History

+ @@ -172,12 +179,12 @@

Period Savings

$0.00

-

Avg Hourly Savings

-

$0.00/hr

+

Avg Monthly Savings

+

$0.00/mo

-

Peak Savings

-

$0.00/hr

+

Peak Savings

+

$0.00/mo

diff --git a/frontend/src/modules/savings-history.ts b/frontend/src/modules/savings-history.ts index 928e4e651..c6cf6da87 100644 --- a/frontend/src/modules/savings-history.ts +++ b/frontend/src/modules/savings-history.ts @@ -12,6 +12,64 @@ Chart.register(...registerables); // Chart instance let savingsChart: Chart | null = null; +// Canonical unit the API returns for per-bucket and summary values. +// estimated_savings in purchase_history is the monthly savings figure +// (it sits alongside monthly_cost in the schema), so all totals summed +// from that column are also monthly. +const API_UNIT = 'monthly' as const; + +export type SavingsUnit = 'hourly' | 'monthly' | 'yearly'; + +// Conversion factors relative to monthly as the canonical unit. +const HOURS_PER_MONTH = 730; // 365.25 * 24 / 12 +const MONTHS_PER_YEAR = 12; + +/** + * Convert a monthly savings value to the chosen display unit. + * The API always returns monthly values; this function converts for display only. + */ +export function convertFromMonthly(monthlyValue: number, unit: SavingsUnit): number { + switch (unit) { + case 'hourly': return monthlyValue / HOURS_PER_MONTH; + case 'monthly': return monthlyValue; + case 'yearly': return monthlyValue * MONTHS_PER_YEAR; + } +} + +/** + * Return the short suffix string for the given unit (e.g. "/hr"). + */ +export function unitSuffix(unit: SavingsUnit): string { + switch (unit) { + case 'hourly': return '/hr'; + case 'monthly': return '/mo'; + case 'yearly': return '/yr'; + } +} + +/** + * Return the adjective for use in stat-card headings (e.g. "Hourly"). + */ +export function unitLabel(unit: SavingsUnit): string { + switch (unit) { + case 'hourly': return 'Hourly'; + case 'monthly': return 'Monthly'; + case 'yearly': return 'Yearly'; + } +} + +/** + * Read the current value of the #savings-unit dropdown. + * Falls back to the API's canonical unit when the element is absent (e.g. in tests + * that don't include the dropdown in their DOM fixture). + */ +function getSelectedUnit(): SavingsUnit { + const el = document.getElementById('savings-unit') as HTMLSelectElement | null; + const val = el?.value ?? API_UNIT; + if (val === 'hourly' || val === 'monthly' || val === 'yearly') return val; + return API_UNIT; +} + /** * Load savings history data based on selected period */ @@ -60,7 +118,7 @@ export async function loadSavingsHistory(): Promise { if (statsEl) statsEl.classList.remove('hidden'); renderSavingsStats(data); - renderSavingsChart(data.data_points, interval); + renderSavingsChart(data.data_points, interval, getSelectedUnit()); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to load savings history:', msg); @@ -190,13 +248,20 @@ function getPeriodDates(period: string): { start: Date; end: Date; interval: 'ho */ function renderSavingsStats(data: SavingsAnalyticsResponse): void { const periodSavingsEl = document.getElementById('period-savings'); - const avgHourlySavingsEl = document.getElementById('avg-hourly-savings'); + const avgSavingsEl = document.getElementById('avg-hourly-savings'); const peakSavingsEl = document.getElementById('peak-savings'); + const avgLabelEl = document.getElementById('avg-savings-label'); + + const unit = getSelectedUnit(); + const suffix = unitSuffix(unit); + const adjective = unitLabel(unit); const summary = data.summary; const dataPoints = data.data_points || []; - // Calculate totals from data points (sum of hourly savings) + // Calculate totals from data points when summary is absent. + // Each data point's total_savings is the sum of estimated_savings + // (a monthly figure) bucketed by the chosen interval. let totalSavings = 0; let peakSavings = 0; @@ -210,19 +275,30 @@ function renderSavingsStats(data: SavingsAnalyticsResponse): void { const avgPerPeriod = dataPoints.length > 0 ? totalSavings / dataPoints.length : 0; - // Use summary if available, otherwise use calculated values - const displayTotal = summary?.total_period_savings ?? totalSavings; - const displayAvg = summary?.average_savings_per_period ?? avgPerPeriod; - const displayPeak = summary?.peak_savings ?? peakSavings; + // Use summary if available, otherwise fall back to calculated values. + // All three values are in the API's canonical monthly unit. + const monthlyTotal = summary?.total_period_savings ?? totalSavings; + const monthlyAvg = summary?.average_savings_per_period ?? avgPerPeriod; + const monthlyPeak = summary?.peak_savings ?? peakSavings; + + // Convert to the user-chosen display unit. + const displayTotal = convertFromMonthly(monthlyTotal, unit); + const displayAvg = convertFromMonthly(monthlyAvg, unit); + const displayPeak = convertFromMonthly(monthlyPeak, unit); if (periodSavingsEl) { + // Period Savings is the cumulative total over the selected date range + // (no per-unit rate suffix -- it is already a dollar total). periodSavingsEl.textContent = formatCurrency(displayTotal); } - if (avgHourlySavingsEl) { - avgHourlySavingsEl.textContent = `${formatCurrency(displayAvg)}/hr`; + if (avgLabelEl) { + avgLabelEl.textContent = `Avg ${adjective} Savings`; + } + if (avgSavingsEl) { + avgSavingsEl.textContent = `${formatCurrency(displayAvg)}${suffix}`; } if (peakSavingsEl) { - peakSavingsEl.textContent = `${formatCurrency(displayPeak)}/hr`; + peakSavingsEl.textContent = `${formatCurrency(displayPeak)}${suffix}`; } } @@ -239,7 +315,7 @@ function formatCurrency(value: number): string { /** * Render savings chart using Chart.js */ -function renderSavingsChart(dataPoints: SavingsDataPoint[], interval: string): void { +function renderSavingsChart(dataPoints: SavingsDataPoint[], interval: string, unit: SavingsUnit = 'monthly'): void { const ctx = document.getElementById('savings-history-chart') as HTMLCanvasElement; if (!ctx) { @@ -261,7 +337,8 @@ function renderSavingsChart(dataPoints: SavingsDataPoint[], interval: string): v }); }); - const savingsData = dataPoints.map(dp => dp.total_savings || 0); + const suffix = unitSuffix(unit); + const savingsData = dataPoints.map(dp => convertFromMonthly(dp.total_savings || 0, unit)); const cumulativeSavings = dataPoints.map(dp => dp.cumulative_savings || 0); if (savingsChart) { @@ -332,7 +409,7 @@ function renderSavingsChart(dataPoints: SavingsDataPoint[], interval: string): v }, title: { display: true, - text: 'Savings per Period', + text: `Savings per Period (${unitLabel(unit)})`, }, }, y1: { @@ -371,10 +448,10 @@ function renderSavingsChart(dataPoints: SavingsDataPoint[], interval: string): v label: function(context) { const value = context.raw as number || 0; if (context.datasetIndex === 1) { - // Cumulative savings + // Cumulative savings -- raw total, no rate suffix return `${context.dataset.label}: $${value.toFixed(2)}`; } - return `${context.dataset.label}: $${value.toFixed(4)}/hr`; + return `${context.dataset.label}: $${value.toFixed(4)}${suffix}`; }, }, }, @@ -418,6 +495,7 @@ function isPurchasesTabActive(): boolean { export function initSavingsHistory(): void { const periodSelect = document.getElementById('savings-period'); const refreshBtn = document.getElementById('refresh-savings-btn'); + const unitSelect = document.getElementById('savings-unit'); if (periodSelect) { periodSelect.addEventListener('change', loadSavingsHistory); @@ -427,6 +505,19 @@ export function initSavingsHistory(): void { refreshBtn.addEventListener('click', loadSavingsHistory); } + // Wire unit dropdown. Using a named handler stored as a property so that + // repeated calls to initSavingsHistory() don't stack duplicate listeners + // (feedback_event_listener_dedup pattern). + if (unitSelect) { + const prevHandler = (unitSelect as HTMLSelectElement & { _unitChangeHandler?: () => void })._unitChangeHandler; + if (prevHandler) { + unitSelect.removeEventListener('change', prevHandler); + } + const unitChangeHandler = (): void => { void loadSavingsHistory(); }; + (unitSelect as HTMLSelectElement & { _unitChangeHandler?: () => void })._unitChangeHandler = unitChangeHandler; + unitSelect.addEventListener('change', unitChangeHandler); + } + let reloadQueued = false; const scheduleReload = (): void => { if (!isPurchasesTabActive() || reloadQueued) return;