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;