Skip to content

Commit 125cee2

Browse files
committed
feat(frontend): add amortize-upfront monthly cost toggle (#1112)
Add a shared "Amortize upfront over term" checkbox that folds the one-time upfront cost evenly across the commitment term, letting every payment type (No Upfront / Partial / All Upfront) be compared on a total-cost-per-month basis. New shared helper `amortizedMonthly(monthlyCost, upfrontCost, termYears)` in utils.ts returns `monthlyCost + upfrontCost / (termYears * 12)`. Guards: term <= 0, non-finite term, or null/non-finite upfront all fall back to monthlyCost unchanged (no NaN/Infinity). Toggle state lives in `state.ts` (`getAmortizeUpfront` / `setAmortizeUpfront` / `subscribeAmortizeUpfront`) persisted under `cudly.amortizeUpfront` in localStorage. Same pattern as `getCostPeriod`/`setCostPeriod`. All views read the same key so they stay in sync across tab switches and page reloads. Views updated: - history.ts: checkbox injected into #history-controls and #purchases-approval-queue-section; both renderHistoryList and renderApprovalQueue use amortizedMonthly when the toggle is on; column header updates to "Monthly Cost (amortized)" when active. subscribeAmortizeUpfront re-renders both tables without a refetch. - inventory.ts: checkbox injected into .section-header-actions; buildCommitmentRow uses amortizedMonthly on the monthly_cost cell; last-fetched commitments cached so subscribeAmortizeUpfront can re-render without an API round-trip. - approval-details.ts: "Monthly cost" column added to the per-rec table; header built via DOM methods (no innerHTML); column label updates to "Monthly cost (amortized)" when toggle is on. Modal reads state at render time, no re-render needed. plans.ts and dashboard.ts have no monthly_cost field on their displayed data types (PlannedPurchase / dashboard summary), so the toggle has no effect there and no changes were needed. Tests: 9 new `amortizedMonthly` unit tests in utils.test.ts covering all payment types and every guard path. Existing approval-details, history, and inventory tests updated for the new column and the new state mock entries. Full suite: 2442 pass, 2 pre-existing timezone failures in utils.test.ts (unrelated). Closes #1112
1 parent 9fada6d commit 125cee2

17 files changed

Lines changed: 354 additions & 35 deletions

‎frontend/src/__tests__/allowed-accounts.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ jest.mock('../state', () => ({
7070
setCurrentAccountIDs: jest.fn(),
7171
subscribeProvider: jest.fn().mockReturnValue(() => {}),
7272
subscribeAccount: jest.fn().mockReturnValue(() => {}),
73+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
74+
setAmortizeUpfront: jest.fn(),
75+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
7376
}));
7477

7578
// ---------------------------------------------------------------------------

‎frontend/src/__tests__/app.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ jest.mock('../state', () => ({
1717
subscribeProvider: jest.fn(),
1818
subscribeAccount: jest.fn(),
1919
getCurrentProvider: jest.fn(() => ''),
20-
getCurrentAccountIDs: jest.fn(() => [])
20+
getCurrentAccountIDs: jest.fn(() => []),
21+
getAmortizeUpfront: jest.fn(() => false),
22+
setAmortizeUpfront: jest.fn(),
23+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
2124
}));
2225

2326
jest.mock('../auth', () => ({

‎frontend/src/__tests__/approval-details.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
* network helpers (those are covered by api-* tests).
88
*/
99

10+
// approval-details.ts now reads getAmortizeUpfront() from state at render
11+
// time. Mock it to return false (default: non-amortized) so existing tests
12+
// continue to verify the base column layout.
13+
jest.mock('../state', () => ({
14+
getAmortizeUpfront: jest.fn(() => false),
15+
}));
16+
1017
import {
1118
computeEffectiveSavingsPct,
1219
formatAccountLabel,
@@ -82,13 +89,13 @@ describe('renderApprovalDetailsBody', () => {
8289
expect(text).toContain('Accounts');
8390
});
8491

85-
it('renders the per-rec table with all 12 columns', () => {
92+
it('renders the per-rec table with all 13 columns (Monthly cost added by issue #1112)', () => {
8693
const rec = makeRec({});
8794
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map());
8895
const headers = Array.from(body.querySelectorAll('.approval-details-table thead th')).map(th => th.textContent);
8996
expect(headers).toEqual([
9097
'Account', 'Provider', 'Service', 'Resource', 'Engine', 'Region',
91-
'Count', 'Term', 'Payment', 'Upfront', 'Monthly savings', 'Eff. savings %',
98+
'Count', 'Term', 'Payment', 'Upfront', 'Monthly cost', 'Monthly savings', 'Eff. savings %',
9299
]);
93100
});
94101

@@ -149,17 +156,19 @@ describe('renderApprovalDetailsBody', () => {
149156
const rec = makeRec({ upfront_cost: 4567.89, savings: 12.5 });
150157
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map());
151158
const cells = body.querySelectorAll('.approval-details-table tbody td');
159+
// col 9 = Upfront, col 10 = Monthly cost (issue #1112), col 11 = Monthly savings
152160
expect(cells[9]?.textContent).toBe('$4,568');
153-
expect(cells[10]?.textContent).toBe('$13');
161+
expect(cells[11]?.textContent).toBe('$13');
154162
});
155163

156164
it('computes effective savings % when on_demand_cost is set, "—" otherwise', () => {
157165
const withBaseline = makeRec({ savings: 30, on_demand_cost: 100 });
158166
const withoutBaseline = makeRec({ id: 'rec-2', savings: 30, on_demand_cost: null, monthly_cost: null });
159167
const body = renderApprovalDetailsBody(makeDetails([withBaseline, withoutBaseline]), new Map());
160168
const rows = body.querySelectorAll('.approval-details-table tbody tr');
161-
expect(rows[0]?.querySelectorAll('td')[11]?.textContent).toBe('30.0%');
162-
expect(rows[1]?.querySelectorAll('td')[11]?.textContent).toBe('—');
169+
// col 12 = Eff. savings % (shifted by +1 due to the new Monthly cost col at index 10)
170+
expect(rows[0]?.querySelectorAll('td')[12]?.textContent).toBe('30.0%');
171+
expect(rows[1]?.querySelectorAll('td')[12]?.textContent).toBe('—');
163172
});
164173

165174
it('annual-savings tooltip does NOT fire on floating-point rounding noise', () => {

‎frontend/src/__tests__/history-approval-queue.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ jest.mock('../state', () => ({
6060
setCurrentAccountIDs: jest.fn(),
6161
subscribeProvider: jest.fn().mockReturnValue(() => {}),
6262
subscribeAccount: jest.fn().mockReturnValue(() => {}),
63+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
64+
setAmortizeUpfront: jest.fn(),
65+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
6366
}));
6467

6568
jest.mock('../recommendations', () => ({

‎frontend/src/__tests__/history-approve-button.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ jest.mock('../state', () => ({
6161
setCurrentAccountIDs: jest.fn(),
6262
subscribeProvider: jest.fn().mockReturnValue(() => {}),
6363
subscribeAccount: jest.fn().mockReturnValue(() => {}),
64+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
65+
setAmortizeUpfront: jest.fn(),
66+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
6467
}));
6568

6669
import * as api from '../api';

‎frontend/src/__tests__/history-cancel-button.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ jest.mock('../state', () => ({
5858
setCurrentAccountIDs: jest.fn(),
5959
subscribeProvider: jest.fn().mockReturnValue(() => {}),
6060
subscribeAccount: jest.fn().mockReturnValue(() => {}),
61+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
62+
setAmortizeUpfront: jest.fn(),
63+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
6164
}));
6265

6366
import * as api from '../api';

‎frontend/src/__tests__/history-cancel-permissions.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jest.mock('../state', () => ({
5757
setCurrentAccountIDs: jest.fn(),
5858
subscribeProvider: jest.fn().mockReturnValue(() => {}),
5959
subscribeAccount: jest.fn().mockReturnValue(() => {}),
60+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
61+
setAmortizeUpfront: jest.fn(),
62+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
6063
}));
6164

6265
// Mock permissions so we can inject arbitrary permission sets, including

‎frontend/src/__tests__/history-retry-button.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ jest.mock('../state', () => ({
6767
setCurrentAccountIDs: jest.fn(),
6868
subscribeProvider: jest.fn().mockReturnValue(() => {}),
6969
subscribeAccount: jest.fn().mockReturnValue(() => {}),
70+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
71+
setAmortizeUpfront: jest.fn(),
72+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
7073
}));
7174

7275
import * as api from '../api';

‎frontend/src/__tests__/history.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jest.mock('../state', () => ({
4141
setCurrentAccountIDs: jest.fn(),
4242
subscribeProvider: jest.fn().mockReturnValue(() => {}),
4343
subscribeAccount: jest.fn().mockReturnValue(() => {}),
44+
getAmortizeUpfront: jest.fn().mockReturnValue(false),
45+
setAmortizeUpfront: jest.fn(),
46+
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
4447
}));
4548

4649
import * as api from '../api';

‎frontend/src/__tests__/inventory.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jest.mock('../state', () => ({
3232
subscribeAccount: jest.fn(() => jest.fn()),
3333
getCurrentProvider: jest.fn(() => ''),
3434
getCurrentAccountIDs: jest.fn(() => []),
35+
getAmortizeUpfront: jest.fn(() => false),
36+
setAmortizeUpfront: jest.fn(),
37+
subscribeAmortizeUpfront: jest.fn(() => jest.fn()),
3538
}));
3639

3740
// inventory.ts routes sub-nav clicks through navigation.switchInventorySubTab

0 commit comments

Comments
 (0)