Skip to content

feat(frontend/recs): add 'On-Demand Monthly' column to recommendations table#322

Merged
cristim merged 2 commits intofeat/multicloud-web-frontendfrom
feat/recs-on-demand-monthly-col
May 6, 2026
Merged

feat(frontend/recs): add 'On-Demand Monthly' column to recommendations table#322
cristim merged 2 commits intofeat/multicloud-web-frontendfrom
feat/recs-on-demand-monthly-col

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 6, 2026

Summary

Adds a new On-Demand Monthly column to the recommendations table, showing the equivalent on-demand monthly cost derived client-side:

on_demand_monthly = monthly_cost + savings + (upfront_cost / (term × 12))
  • Renders formatCurrency for non-null monthly_cost; em-dash () when null
  • Sortable (null rows sort to end, consistent with Monthly Cost)
  • Filterable with numeric filter predicates (>, <, = — NaN for null avoids false = 0 matches)
  • New exported helper onDemandMonthly() with 7 unit tests

Changes

  • frontend/src/state.ts: Added 'on_demand_monthly' to RecommendationsColumnId union
  • frontend/src/recommendations.ts: Added onDemandMonthly() helper, wired into SORTABLE_NUMERIC_COLUMNS, SORT_HEADER_LABELS, categoricalCellValue, numericCellValue, NUMERIC_COLUMNS, FILTERABLE_COLUMNS, buildVariantRowMarkup. Incremented TABLE_COL_COUNT from 13 → 14.
  • frontend/src/__tests__/recommendations.test.ts: Added describe('onDemandMonthly', ...) with 7 unit tests, 3 rendering tests, updated column-count and filter-trigger tests.

Coordination note

Sibling parallel work in flight: #318 (feat/recs-column-visibility) refactors the two column-ordering arrays. If #318 merges first, will rebase and mechanically add on_demand_monthly to the consolidated source.

Will rebase if #318 (column-array refactor) merges first.

Closes #317

Summary by CodeRabbit

  • New Features

    • Added an On-Demand Monthly column to the recommendations table for clearer cost comparison; it is sortable and participates in filtering and ranking.
  • Tests

    • Expanded test coverage for On-Demand Monthly rendering, formatting (currency vs placeholder), sorting, filtering, and interaction with existing Monthly Cost and Effective % displays.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4568727c-8b0b-4aea-bf01-74eeb0776167

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new "On‑Demand Monthly" column to the recommendations table: computes the unsubsidised monthly baseline by reversing upfront amortization, exposes helpers (onDemandMonthly, effectiveMonthlySavings, effectiveSavingsPct), wires the column into sort/filter/header/row rendering, and adds unit tests for formatting, filtering, and sorting. (50 words)

Changes

On-Demand Monthly Column

Layer / File(s) Summary
Type System
frontend/src/state.ts
RecommendationsColumnId extended to include 'on_demand_monthly'.
Core Calculation
frontend/src/recommendations.ts
New exported helpers: `onDemandMonthly(r: LocalRecommendation): number
Sort & Filter Integration
frontend/src/recommendations.ts
on_demand_monthly added to SORTABLE_NUMERIC_COLUMNS, NUMERIC_COLUMNS, FILTERABLE_COLUMNS; numeric extractor returns onDemandMonthly(r) ?? Number.NaN; categorical extractor returns empty string.
Header / Labels
frontend/src/recommendations.ts
Header label map extended with 'on_demand_monthly': 'On-Demand Monthly' and included in sortable header wiring.
Row Rendering
frontend/src/recommendations.ts
renderVariantRowMarkup computes odm / odmText via onDemandMonthly and renders the new column cell (currency when monthly_cost non-null, em‑dash when null), adjusting row cell layout.
Tests
frontend/src/__tests__/recommendations.test.ts
Tests import the new helpers; added dedicated onDemandMonthly suite (fixtures, term edge cases, amortization); updated header count to 13 sortable columns; added formatting, filtering, and sort‑wiring tests; updated Monthly Cost / Effective % tests for new column ordering.

Sequence Diagram(s)

(Not included — UI-only column addition with local computation, no multi-component sequential flow.)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • LeanerCloud/CUDly#277: Modifies recommendations logic for on‑demand baseline and effective savings; strongly related to these helper and wiring changes.
  • LeanerCloud/CUDly#242: Adds/exports recommendation helpers and integrates new numeric columns; overlaps with exported helpers and tests.
  • LeanerCloud/CUDly#160: Previously modified recommendations column/filter/sort plumbing touched by this PR.

Poem

🐰 I dug a new column under moonlit code,
On‑demand monthly — now plainly showed.
Amortized math made neat and sunny,
Cells that sparkle like carrot money.
Hop, click, sort — the table’s ready and bold.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% 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 title accurately and concisely summarizes the main change: adding a new 'On-Demand Monthly' column to the recommendations table, which is the primary objective of this PR.
Linked Issues check ✅ Passed The PR fully implements all coding objectives from issue #317: new on_demand_monthly column with correct formula, sortable/filterable numeric behavior, null handling (em-dash rendering and POSITIVE_INFINITY sort), NaN filtering semantics, unit tests, and all required integrations.
Out of Scope Changes check ✅ Passed All changes directly support the stated objective of adding the On-Demand Monthly column; no unrelated modifications detected in state.ts, recommendations.ts, or test files.

✏️ 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/recs-on-demand-monthly-col

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

@cristim cristim added priority/p2 Backlog-worthy severity/low Minor harm urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours type/feat New capability triaged Item has been triaged labels May 6, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

Sibling parallel work in flight: #320 (#325) — sharing this for merge-order awareness. PR #325 touches only the modal area (openPurchaseModal, renderPurchaseModalRow, updatePurchaseModalTotals) and does not overlap with your column-map changes.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@frontend/src/__tests__/recommendations.test.ts`:
- Around line 3510-3559: The tests scan all <td> text so they can be satisfied
by other columns; update the two assertions to target the actual
on_demand_monthly cell(s) and add a regression that a filter on
on_demand_monthly excludes nulls: in the two tests that use
baseRec()/loadRecommendations()/state.getRecommendations mock data, query the
cell(s) for the on_demand_monthly column (e.g. select using the same attribute
used for the header th[data-sort="on_demand_monthly"] to find the matching td
for that column) and assert the exact cell text is '$150' (when monthly_cost
present) or '—' (when monthly_cost null); then add a small follow-up test that
calls loadRecommendations with state.getRecommendations returning a null
monthly_cost row and applies state.setRecommendationsFilter({ on_demand_monthly:
{ kind: 'expr', expr: '0' } }) (or the existing filter wiring API) and assert
the null row does not match (no '$0' / no match), ensuring the non-matching
sentinel behavior is pinned.

In `@frontend/src/recommendations.ts`:
- Around line 357-360: The current fallback in on_demand_monthly
(on_demand_monthly: (r) => onDemandMonthly(r) ?? Number.POSITIVE_INFINITY)
causes null on_demand_monthly rows to appear first when sorting descending;
instead, update the sorting logic in groupsInSortOrder to explicitly handle
nulls so they always sort last regardless of direction: have on_demand_monthly
produce a pair/flag (or leave null) and modify the numeric comparator used by
groupsInSortOrder to check for nulls first (if one value is null and the other
isn’t, treat the null as greater so it goes last), otherwise compare numeric
values normally taking the sort direction into account; reference
on_demand_monthly, onDemandMonthly, and groupsInSortOrder when making this
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84785109-0085-47a0-bd81-b3c0d36ebf5c

📥 Commits

Reviewing files that changed from the base of the PR and between 7032056 and 7480f70.

📒 Files selected for processing (3)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts

Comment thread frontend/src/__tests__/recommendations.test.ts
Comment thread frontend/src/recommendations.ts
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

Addressed CodeRabbit pass 1 findings:

  1. Null-last sort (recommendations.ts): POSITIVE_INFINITY * direction was placing null rows first in descending order. Added an explicit null-last guard in groupsInSortOrder before the numeric comparator — nulls now sort to the end regardless of direction. This also fixes the same behavior for monthly_cost and effective_savings_pct.

  2. Pinned test assertions (recommendations.test.ts): Changed the two rendering tests from scanning all <td> text to pinning the specific on_demand_monthly cell by column index. Added a filter regression test (null monthly_cost does not match = 0) that pins the NaN sentinel behavior using mockReturnValueOnce to avoid mock leakage.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim Thanks for the detailed summary of the fixes! Both changes look well-reasoned:

  1. The explicit null-last guard in groupsInSortOrder is the right approach — POSITIVE_INFINITY * direction (which becomes -POSITIVE_INFINITY in descending) was indeed the root cause of nulls floating to the top in descending order. The guard also being a net improvement for monthly_cost and effective_savings_pct is a nice bonus.

  2. Pinning the on_demand_monthly cell by column index rather than scanning all <td> text makes the rendering tests much more precise, and the mockReturnValueOnce scoping for the NaN sentinel filter test is the correct way to avoid mock state leakage between tests.

Triggering a fresh incremental review now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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/recommendations.ts (1)

357-360: ⚡ Quick win

Update stale sort-behavior comment to match the comparator logic.

The inline note says nulls go to the top in descending order, but groupsInSortOrder now enforces null-last for both directions. Please update this comment to avoid future regressions.

Suggested diff
-  // POSITIVE_INFINITY places null rows at the bottom in ascending order and
-  // at the top in descending — consistent with monthly_cost.
+  // POSITIVE_INFINITY is used as the null sentinel; groupsInSortOrder
+  // applies an explicit null-last guard so nullish rows stay de-emphasised
+  // in both ascending and descending sorts.
🤖 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/recommendations.ts` around lines 357 - 360, The comment on the
on_demand_monthly mapper is stale: update the inline note around
on_demand_monthly: (r) => onDemandMonthly(r) ?? Number.POSITIVE_INFINITY to
state that null/undefined monthly_cost values are mapped to POSITIVE_INFINITY so
they are sorted last (null-last) in both ascending and descending order because
groupsInSortOrder enforces null-last for all directions; adjust the wording to
remove the claim that nulls go to the top in descending order and explicitly
mention null-last behavior consistent with groupsInSortOrder.
🤖 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/recommendations.ts`:
- Around line 357-360: The comment on the on_demand_monthly mapper is stale:
update the inline note around on_demand_monthly: (r) => onDemandMonthly(r) ??
Number.POSITIVE_INFINITY to state that null/undefined monthly_cost values are
mapped to POSITIVE_INFINITY so they are sorted last (null-last) in both
ascending and descending order because groupsInSortOrder enforces null-last for
all directions; adjust the wording to remove the claim that nulls go to the top
in descending order and explicitly mention null-last behavior consistent with
groupsInSortOrder.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5873bc10-2e0a-4953-87d6-767d0684c72e

📥 Commits

Reviewing files that changed from the base of the PR and between 7480f70 and 573927c.

📒 Files selected for processing (2)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts

cristim added 2 commits May 6, 2026 20:06
…s table

Adds a new on_demand_monthly column that shows the equivalent on-demand
monthly cost, derived client-side as:
  on_demand_monthly = monthly_cost + savings + (upfront_cost / (term × 12))

- Renders formatCurrency for non-null monthly_cost; em-dash otherwise
- Sortable (null rows sort to end via POSITIVE_INFINITY)
- Filterable with numeric filter predicates (NaN for null avoids false = 0 matches)
- Exports onDemandMonthly() helper for reuse and testability

Closes #317
…sertions

Two CodeRabbit findings addressed:

1. groupsInSortOrder: POSITIVE_INFINITY * direction = -Infinity for desc,
   placing null rows first. Add explicit null-last guard before the numeric
   comparator so nulls always sort to the end regardless of direction. Fixes
   monthly_cost and effective_savings_pct descending sort as well.

2. On-Demand Monthly rendering tests: assertions scanned all <td> elements
   and could pass from other columns. Switch to column-index pinning via
   th[data-sort="on_demand_monthly"] and add a filter regression test that
   pins the NaN sentinel (null monthly_cost must not match = 0 filter).
@cristim cristim force-pushed the feat/recs-on-demand-monthly-col branch from 573927c to 331e73a Compare May 6, 2026 18:07
@cristim cristim merged commit 486822c into feat/multicloud-web-frontend May 6, 2026
6 checks passed
cristim added a commit that referenced this pull request May 6, 2026
#322 (now merged) added the on_demand_monthly column to the recs table,
bringing COLUMN_DEFS.length to 13. The visibility-feature test was
written when the count was 12 — update it post-rebase so the suite
stays green.
cristim added a commit that referenced this pull request May 6, 2026
Three CR nitpicks, all in __tests__/recommendations.test.ts:

1. "changing the dropdown calls setCostPeriod and triggers rerender"
   now seeds getCostPeriod with mockReturnValueOnce('monthly') +
   mockReturnValue('hourly') and asserts the rerender actually
   rebuilt the savings-column header to "Savings / hr". The previous
   version would have passed even if the change handler stopped
   rebuilding the DOM.

2. "invalid value in localStorage falls back to ..." now seeds the
   in-memory state with a non-default value first (setCostPeriodFn
   ('hourly')) before injecting the invalid localStorage value, so
   the test fails if getCostPeriod() incorrectly returns a stale
   in-memory cache instead of re-validating localStorage on each
   call. Also renamed for clarity ("static default, not prior
   in-memory state").

3. Added two new tests in the "table cells scale with period"
   describe block that pin the scaled-numeric contract:

   - yearly sort orders savings cells by yearly-scaled value (asc):
     two recs pre-sorted DESC are reordered ASC by the production
     sort which routes through scaleCost. A no-op scale that
     preserves input order would fail.

   - hourly numeric filter compares the scaled (per-hour) savings,
     not the raw monthly value: two recs (360 and 720 monthly) with
     a ">0.75" filter — both pass the raw filter, only r-high passes
     after hourly scaling (1.0 vs 0.5). A regression that drops the
     scaleCost call inside numericCellValue would surface here.

Plus the rebase onto current feat/multicloud-web-frontend (PR #322's
on-demand monthly column landed there). The base column row now has
14 data columns; on_demand_monthly cell uses formatCostForPeriod so
it scales with the active period like savings/monthly_cost, and
SORTABLE_NUMERIC_COLUMNS.on_demand_monthly + numericCellValue.
on_demand_monthly are wired through scaleCost. Header label for
on_demand_monthly now period-scales via getColumnLabel
("On-Demand Monthly" / "On-Demand / hr" / etc.). TABLE_COL_COUNT
bumped from 14 to 15 to account for the extra column.

235 frontend tests pass.
cristim added a commit that referenced this pull request May 6, 2026
…y) (#328)

* feat(frontend/recs): cost-period selector (hourly/daily/monthly/yearly)

Adds a "Show costs" dropdown in the recs toolbar letting users view all
cost / savings / on-demand columns scaled to hourly · daily · monthly
(default) · yearly. Engineering thinks hourly, finance thinks yearly,
cost-optimisation review thinks monthly — instead of mentally
multiplying by 24 × 30 / 12, one dropdown rescales the displayed values.

Affects display values, headers, sort + filter (uses scaled values so a
"< $1" filter at hourly does what it should), summary card, and fan-out
totals. Does NOT scale: Upfront Cost (one-time), Effective % (already a
ratio), Term (already in years).

Conversion factors (consistent with industry quoting):
  hourly = monthly / (24 * 30)   — 720 hrs/mo
  daily  = monthly / 30
  yearly = monthly * 12

Selection persists in localStorage (`cudly.recs.costPeriod`). When the
persisted value is corrupted/invalid, getCostPeriod() falls back to the
static default ('monthly') rather than to whatever leaked into the
in-memory cache from a prior setCostPeriod() call — defensive against
storage drift across versions.

Tests: 50+ new test cases under "Issue #319: cost-period selector" plus
"Issue #319: localStorage persistence". The persistence tests install a
Map-backed `localStorage` shim per test (the global setup mocks
localStorage as a noop) so we exercise the real persistence semantics.

Closes #319

* test(frontend/recs): address CodeRabbit pass-1 nitpicks on PR #328

Three CR nitpicks, all in __tests__/recommendations.test.ts:

1. "changing the dropdown calls setCostPeriod and triggers rerender"
   now seeds getCostPeriod with mockReturnValueOnce('monthly') +
   mockReturnValue('hourly') and asserts the rerender actually
   rebuilt the savings-column header to "Savings / hr". The previous
   version would have passed even if the change handler stopped
   rebuilding the DOM.

2. "invalid value in localStorage falls back to ..." now seeds the
   in-memory state with a non-default value first (setCostPeriodFn
   ('hourly')) before injecting the invalid localStorage value, so
   the test fails if getCostPeriod() incorrectly returns a stale
   in-memory cache instead of re-validating localStorage on each
   call. Also renamed for clarity ("static default, not prior
   in-memory state").

3. Added two new tests in the "table cells scale with period"
   describe block that pin the scaled-numeric contract:

   - yearly sort orders savings cells by yearly-scaled value (asc):
     two recs pre-sorted DESC are reordered ASC by the production
     sort which routes through scaleCost. A no-op scale that
     preserves input order would fail.

   - hourly numeric filter compares the scaled (per-hour) savings,
     not the raw monthly value: two recs (360 and 720 monthly) with
     a ">0.75" filter — both pass the raw filter, only r-high passes
     after hourly scaling (1.0 vs 0.5). A regression that drops the
     scaleCost call inside numericCellValue would surface here.

Plus the rebase onto current feat/multicloud-web-frontend (PR #322's
on-demand monthly column landed there). The base column row now has
14 data columns; on_demand_monthly cell uses formatCostForPeriod so
it scales with the active period like savings/monthly_cost, and
SORTABLE_NUMERIC_COLUMNS.on_demand_monthly + numericCellValue.
on_demand_monthly are wired through scaleCost. Header label for
on_demand_monthly now period-scales via getColumnLabel
("On-Demand Monthly" / "On-Demand / hr" / etc.). TABLE_COL_COUNT
bumped from 14 to 15 to account for the extra column.

235 frontend tests pass.

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant