Skip to content

feat(frontend): amortize-upfront monthly cost toggle - #1114

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
feat/amortized-monthly-cost-toggle
Jun 8, 2026
Merged

cristim merged 1 commit into
feat/multicloud-web-frontendfrom
feat/amortized-monthly-cost-toggle

Conversation

@cristim

@cristim cristim commented Jun 8, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds a shared "Amortize upfront over term" checkbox that folds the one-time upfront cost evenly across the term so No Upfront / Partial / All Upfront commitments can be compared on a total-cost-per-month basis.
  • New helper amortizedMonthly(monthlyCost, upfrontCost, termYears) in utils.ts; guards against term <= 0, non-finite inputs, and null upfront (all return monthlyCost unchanged).
  • Toggle persisted in localStorage (cudly.amortizeUpfront) via state.ts get/set/subscribe pattern matching the existing costPeriod slice; all views share the same key and re-render in sync without refetching.

Views changed

View What changed
history.ts Checkbox in #history-controls and approval-queue section; both tables re-render on toggle; column header becomes "Monthly Cost (amortized)" when active
inventory.ts Checkbox in section-header-actions; buildCommitmentRow applies amortizedMonthly; last-fetch cached for zero-refetch re-render
approval-details.ts New "Monthly cost" column in the per-rec table; header built via DOM (no innerHTML); reads toggle state at modal-render time

plans.ts and dashboard.ts have no monthly_cost field on their displayed types (PlannedPurchase / dashboard summary) -- no changes needed there.

Test plan

  • 9 new amortizedMonthly unit tests (All Upfront, Partial, No Upfront, term<=0 guard, non-finite term, null/undefined/non-finite upfront)
  • approval-details.test.ts updated for the new 13-column layout and shifted cell indices
  • All 12 test files whose jest.mock('../state') lacked getAmortizeUpfront/subscribeAmortizeUpfront updated
  • Full suite: 2442 pass, 1 skipped, 2 pre-existing timezone failures (unrelated, in utils.test.ts)
  • tsc --noEmit clean

Closes #1112

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added "Amortize upfront over term" toggle that spreads upfront costs evenly across commitment terms in cost displays across Purchase History, Approval Queue, Approval Details, and Active Commitments sections.
    • Toggle state persists locally for session continuity.
  • Tests

    • Extended test mocks and added new test coverage for amortization calculation logic.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/low Minor harm urgency/eventually No deadline impact/many Affects most users effort/m Days type/feat New capability labels Jun 8, 2026
@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 6 minutes and 18 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 020cd385-0f9e-4772-8e47-99c817fb99fa

📥 Commits

Reviewing files that changed from the base of the PR and between 9fada6d and 125cee2.

📒 Files selected for processing (17)
  • frontend/src/__tests__/allowed-accounts.test.ts
  • frontend/src/__tests__/app.test.ts
  • frontend/src/__tests__/approval-details.test.ts
  • frontend/src/__tests__/history-approval-queue.test.ts
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-cancel-button.test.ts
  • frontend/src/__tests__/history-cancel-permissions.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/history.test.ts
  • frontend/src/__tests__/inventory.test.ts
  • frontend/src/__tests__/utils.test.ts
  • frontend/src/__tests__/xss-provider-class.test.ts
  • frontend/src/approval-details.ts
  • frontend/src/history.ts
  • frontend/src/inventory.ts
  • frontend/src/state.ts
  • frontend/src/utils.ts
📝 Walkthrough

Walkthrough

This PR implements an "Amortize upfront over term" toggle allowing users to view monthly costs as either raw recurring expenses (default) or amortized totals that spread upfront payments evenly across the commitment term. Changes include new state management, a calculation utility, and UI updates across four cost-display surfaces (approval details, purchase history, approval queue, active commitments).

Changes

Amortized Monthly Cost Toggle

Layer / File(s) Summary
State management and amortization calculation
frontend/src/state.ts, frontend/src/utils.ts, frontend/src/__tests__/utils.test.ts
Introduces getAmortizeUpfront, setAmortizeUpfront, and subscribeAmortizeUpfront with localStorage persistence and subscriber notification. Adds amortizedMonthly utility that computes monthly cost + upfront/(term years × 12), with guards for non-finite/invalid inputs. Comprehensive test suite validates all-upfront, partial-upfront, and no-upfront scenarios plus edge cases.
Approval details modal
frontend/src/approval-details.ts, frontend/src/__tests__/approval-details.test.ts
Conditionally labels the monthly-cost column as "(amortized)" when the toggle is enabled and computes each recommendation row's displayed monthly cost via amortizedMonthly or raw monthly_cost. Test assertions updated to reflect the new 13-column table layout and shifted column indices.
Purchase history and approval queue views
frontend/src/history.ts, frontend/src/__tests__/history.test.ts
Wires subscribeAmortizeUpfront in setupHistoryHandlers to re-render both tables and synchronize checkbox state when the toggle flips. Introduces mountAmortizeCheckbox and syncAmortizeCheckbox helpers for idempotent checkbox mounting. Each table row computes monthly cost using amortized or raw values based on current state, and the column header reflects the active mode.
Active commitments inventory view
frontend/src/inventory.ts, frontend/src/__tests__/inventory.test.ts
Caches fetched commitments and active context to enable efficient re-renders on toggle changes without re-fetching. Wires the amortize subscription and introduces mountInventoryAmortizeCheckbox and wireAmortizeSubscription to manage the checkbox UI and subscriber registration. Row rendering uses amortizedMonthly when enabled; column header dynamically labels as "(amortized)".
Test infrastructure
frontend/src/__tests__/{allowed-accounts,app,history-approval-queue,history-approve-button,history-cancel-button,history-cancel-permissions,history-retry-button,xss-provider-class}.test.ts
All test files' ../state mocks are extended with the three new state APIs (getAmortizeUpfront, setAmortizeUpfront, subscribeAmortizeUpfront) so the tested modules can call these exports without unmocked implementations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • LeanerCloud/CUDly#375: Updates to approval-details.ts and its test file's per-recommendation table structure, directly connected to this PR's monthly-cost column and table layout changes.
  • LeanerCloud/CUDly#587: Introduced allowed-accounts.test.ts, which is extended in this PR to mock the new state APIs.
  • LeanerCloud/CUDly#792: Modified history.ts and inventory.ts table "Monthly Cost" rendering; this PR builds on those same surfaces by adding the amortize-toggle computation and display logic.

Poem

🐰 Amortized Dreams

A checkbox brings clarity bright,
Where upfront payments spread out just right,
No more zeros that trick the eye—
Amortized costs let true expenses fly.
Run your math, compare with ease,
Apples to apples, commit with peace!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(frontend): amortize-upfront monthly cost toggle' directly and specifically describes the main change—adding an amortize-upfront toggle feature to the frontend.
Linked Issues check ✅ Passed All acceptance criteria from #1112 are met: checkbox toggle added with localStorage persistence [#1112], amortized value formula correctly implemented [#1112], column headers updated [#1112], shared helper function created [#1112], edge cases handled [#1112], and unit tests added [#1112].
Out of Scope Changes check ✅ Passed All changes are scoped to the amortize-upfront toggle feature: test mocks updated, utils.ts helper added, state.ts persistence added, and views updated consistently. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/amortized-monthly-cost-toggle

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
frontend/src/state.ts (1)

385-395: 💤 Low value

Consider adding change detection before notifying listeners.

For consistency with setCurrentProvider (lines 96-104) and setCurrentAccountIDs (lines 147-158), you could check whether the value actually changed before notifying subscribers, avoiding unnecessary re-renders when the same value is set twice.

♻️ Proposed change-detection pattern
 export function setAmortizeUpfront(value: boolean): void {
+  const changed = amortizeUpfrontMemory !== value;
   amortizeUpfrontMemory = value;
   try {
     localStorage.setItem(AMORTIZE_UPFRONT_LS_KEY, String(value));
   } catch {
     // Non-fatal; in-memory fallback remains correct for the session.
   }
+  if (changed) {
     amortizeListeners.forEach((cb) => {
       try { cb(); } catch (err) { console.warn('subscribeAmortizeUpfront listener error:', err); }
     });
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/state.ts` around lines 385 - 395, setAmortizeUpfront currently
always writes to local storage and calls amortizeListeners even if
amortizeUpfrontMemory is unchanged; update the function to capture the previous
value (amortizeUpfrontMemory), compare it to the incoming value, and only
proceed to set amortizeUpfrontMemory, write AMORTIZE_UPFRONT_LS_KEY to
localStorage and iterate amortizeListeners (still protecting each callback with
try/catch) when the value actually changes so subscribers aren't notified on
no-op sets.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@frontend/src/state.ts`:
- Around line 385-395: setAmortizeUpfront currently always writes to local
storage and calls amortizeListeners even if amortizeUpfrontMemory is unchanged;
update the function to capture the previous value (amortizeUpfrontMemory),
compare it to the incoming value, and only proceed to set amortizeUpfrontMemory,
write AMORTIZE_UPFRONT_LS_KEY to localStorage and iterate amortizeListeners
(still protecting each callback with try/catch) when the value actually changes
so subscribers aren't notified on no-op sets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a6d671e2-27a1-4b77-b9a8-7c3a756da7be

📥 Commits

Reviewing files that changed from the base of the PR and between 5a205a6 and a95d3b8.

📒 Files selected for processing (17)
  • frontend/src/__tests__/allowed-accounts.test.ts
  • frontend/src/__tests__/app.test.ts
  • frontend/src/__tests__/approval-details.test.ts
  • frontend/src/__tests__/history-approval-queue.test.ts
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-cancel-button.test.ts
  • frontend/src/__tests__/history-cancel-permissions.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/history.test.ts
  • frontend/src/__tests__/inventory.test.ts
  • frontend/src/__tests__/utils.test.ts
  • frontend/src/__tests__/xss-provider-class.test.ts
  • frontend/src/approval-details.ts
  • frontend/src/history.ts
  • frontend/src/inventory.ts
  • frontend/src/state.ts
  • frontend/src/utils.ts

@cristim
cristim force-pushed the feat/amortized-monthly-cost-toggle branch from a95d3b8 to 989a200 Compare June 8, 2026 13:58
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

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
@cristim
cristim force-pushed the feat/amortized-monthly-cost-toggle branch from 989a200 to 125cee2 Compare June 8, 2026 14:54
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@cristim
cristim merged commit b31af08 into feat/multicloud-web-frontend Jun 8, 2026
6 checks passed
@cristim
cristim deleted the feat/amortized-monthly-cost-toggle branch June 8, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant