Skip to content

fix(frontend/recs): enforce one-variant-per-cell radio selection (closes #224)#231

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/issue-224-cell-radio
May 3, 2026
Merged

fix(frontend/recs): enforce one-variant-per-cell radio selection (closes #224)#231
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/issue-224-cell-radio

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 3, 2026

Closes #224. After PR #195's per-(term, payment) fan-out, the rec-table checkboxes had no mutual-exclusion logic — manual checking accumulated sibling commitments AND select-all over-committed 6× per cell (3 cells × 6 variants = 18 selected instead of the intended 3). This PR enforces "at most one variant per cell selected at any time", where cell = (provider, account, service, region, resource_type, engine).

Summary

Out of scope (deliberate)

Test plan

  • 4 new tests in frontend/src/__tests__/recommendations.test.ts 'Issue #224: one-variant-per-cell radio selection' describe:
    • (a) checking variant B in same cell deselects sibling variant A
    • (b) selecting in cell X does not affect cell Y selections
    • (c) select-all picks exactly one variant per cell (3 cells × 6 variants → 3 selected)
    • (d) select-all picks highest-effective-savings (amortized) per cell — concrete fixture where the highest-RAW-savings variant has the LOWEST effective and is NOT picked
  • tsc --noEmit clean. Pre-commit hooks (frontend tests + build + scanners) all pass on the local commit.

Summary by CodeRabbit

  • Bug Fixes

    • Enforced one selected recommendation variant per physical resource/cell; selecting a variant now deselects any other selected variant for the same resource.
    • "Select all" now clears stale selections and chooses one optimal variant per resource, using effective monthly savings as the tie-breaker.
  • Tests

    • Added comprehensive tests covering per-resource deselection on toggle, cross-resource independence, select-all collapsing behavior, and bulk-purchase selection scenarios.

 #224)

After PR #195 (issue #188) the recommendations refresh fans out across
2 terms × 3 payments per `(provider, account, service, region,
resource_type, engine)` cell — up to 6 alternative rec rows per
physical resource. They're alternatives, not additions, but the
checkboxes had no mutual-exclusion logic, producing wrong purchase
intent in two ways:

1. **Manual checking**: a user could check `1yr/all-upfront` AND
   `3yr/no-upfront` for the same EC2 m5.large in the same account.
   Both fed into the purchase plan → double commitment for one
   resource.

2. **`select-all` was the destructive form**: every visible row got
   added, so 3 cells × 6 variants = 18 commitments instead of the
   3 the user expected when clicking "buy what's recommended". 6× the
   intended spend per cell.

# What changes

- New `cellKey(rec)` helper returns the
  `(provider, cloud_account_id, service, region, resource_type, engine)`
  prefix (same as the scheduler ID encoding from #189, minus the
  `(term, payment)` suffix). Recs sharing this key are alternatives
  for the same physical resource.

- Per-row checkbox change handler now enforces radio behaviour: on
  check, scan the visible-recs list for any sibling in the same cell
  that's already selected, deselect it FIRST, then add the new rec.
  Cells are independent — selecting in cell X doesn't touch cell Y's
  selection.

- `select-all` handler rewritten: clear current selection, then call
  new `pickBestVariantPerCell(recs)` which groups by cell and picks
  the variant with the highest **effective monthly savings**:
  `effective = savings - (upfront_cost / (term * 12))`. Amortizing
  the upfront over the commitment term means a 3yr/all-upfront with
  a huge lump-sum doesn't beat a 1yr/no-upfront just on raw
  `savings`. Sibling issue #223 will replace this tiebreaker with
  "matches resolved GlobalConfig.DefaultTerm + DefaultPayment" when
  it lands; until then, amortized savings is the right deterministic
  default.

# Out of scope (deliberate)

- **Native `<input type="radio" name="cell-X">` markup**. Per the
  issue: "Designer call — if cell-grouping (sibling issue #226)
  lands first, radios become visually correct." Stayed with
  checkboxes-with-radio-behaviour for this PR; markup switch waits
  for cell-grouping visual.
- **Default-select per cell from settings** (sibling #223).

# Tests

4 new tests in `frontend/src/__tests__/recommendations.test.ts`
inside the `'Issue #224: one-variant-per-cell radio selection'`
describe block:

  (a) Manual toggle within a cell — checking variant B with variant
      A already selected: A is removed, B is added. Sibling A was
      not also added; B was not also removed.
  (b) Cross-cell independence — selecting in cell X must NOT remove
      cell Y's existing selection.
  (c) `select-all` collapses 18 → 3: 3 cells × 6 variants. After
      click, exactly 3 add calls; clearSelectedRecommendations was
      called first to drop stale state.
  (d) Tiebreaker pin — single cell with 3 variants whose
      `(savings, upfront_cost, term)` produce known amortized values
      ($200, $300, $400). The middle variant wins ($400 effective)
      despite the high-upfront variant having $1200 raw savings,
      proving the amortization is actually computed.

`tsc --noEmit` clean. `npx jest --testPathPatterns="recommendations"`
exit 0. `npm run build` (webpack production) exit 0.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 380ea534-c5a8-4072-8961-c470f404a73c

📥 Commits

Reviewing files that changed from the base of the PR and between a89f8c9 and 41de9a5.

📒 Files selected for processing (2)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/src/recommendations.ts
  • frontend/src/tests/recommendations.test.ts

📝 Walkthrough

Walkthrough

Enforces one-variant-per-physical-resource selection: adds cellKey and pickBestVariantPerCell, makes “select all” collapse visible rows to one best-per-cell (amortized monthly savings), and updates per-row checkbox handling to evict same-cell selected siblings. Tests validate manual toggle, cross-cell independence, select-all collapse, and amortized tiebreaker.

Changes

Recommendation Cell Selection Enforcement

Layer / File(s) Summary
Data Shape / Helpers
frontend/src/recommendations.ts (lines 145–192)
Adds cellKey(rec) to derive a physical-resource identity and pickBestVariantPerCell(recs) to group recs by cellKey and choose the variant with highest amortized effective monthly savings (months clamped to >=12).
Select-All Behavior
frontend/src/recommendations.ts (lines 1982–1991)
Select-all handler now clears prior selections and adds only pickBestVariantPerCell(recommendations) from the currently visible/filtered list instead of selecting every visible row.
Per-Row Checkbox / Radio Enforcement
frontend/src/recommendations.ts (lines 2003–2026)
Per-row checkbox change handler: on check, compute cellKey, remove any other selected rec(s) sharing that cellKey from the full loaded set, then add the toggled id; on uncheck, remove only the toggled id.
Tests / Validation
frontend/src/__tests__/recommendations.test.ts (extended mocks at lines ~38–41; new regression at ~375–426; new describe block 2067–2233)
Adds regression test for selecting a visible variant evicting a same-cell hidden sibling, extends state mocks (getRecommendationByID, getVisibleRecommendations()), and adds a Issue #224`` suite covering manual deselection, cross-cell independence, select-all collapsing to one-per-cell, and amortized-savings tiebreaker for select-all.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Recommendations UI
  participant State as Selection State Store
  participant Data as Loaded / Visible Recommendations
  UI->>State: user clicks "select all"
  State->>Data: getVisibleRecommendations()
  Data-->>State: visible recs
  State->>State: compute pickBestVariantPerCell(visible recs)
  State->>State: clear existing selections
  State->>State: addSelectedRecommendation(best-per-cell ids)
  State-->>UI: update checkboxes (one-per-cell)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I hopped through lists and cleared the mess,
Evicted twins so buyers buy no less.
One choice per burrow, no duplicate spree,
Select-all now gentle, one-per-cell, whee! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 clearly and concisely summarizes the main change: enforcing one-variant-per-cell radio selection to fix issue #224.
Linked Issues check ✅ Passed All coding requirements from issue #224 are implemented: cellKey helper, per-row radio-behavior checkbox handler with sibling eviction from full loaded set, select-all selects best-per-cell with effective monthly savings tiebreaker, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes align with issue #224 objectives; test-only additions and logic enforcement are within scope; radio markup and grouping UI are correctly deferred as out-of-scope per the issue.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-224-cell-radio

Review rate limit: 3/5 reviews remaining, refill in 21 minutes and 51 seconds.

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

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 3, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/recommendations.ts`:
- Around line 179-182: The current defensive clamp uses monthsInTerm =
Math.max(1, r.term * 12) which treats a malformed term === 0 as 1 month; change
the clamp to ensure a minimum of 12 months (1 year) before dividing: compute
monthsInTerm as Math.max(12, r.term * 12) (or Math.max(12, (r.term || 1) * 12)
to handle missing/falsy term values) and keep the rest of the calculation that
returns r.savings - (r.upfront_cost / monthsInTerm) unchanged.
- Around line 2013-2021: When evicting sibling variants, don't iterate the
filtered recommendations list; use the full loaded recommendation set so hidden
siblings get deselected too. Replace the loop "for (const r of recommendations)"
with iteration over the full collection (e.g., state.getAllRecommendations() or
the loadedRecommendations array) and keep the existing logic: compute newCell
via cellKey(newRec), check "r.id !== id && selected.has(r.id) && cellKey(r) ===
newCell", and call state.removeSelectedRecommendation(r.id) for matches.
🪄 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: 53724e9a-f05c-45ef-bb97-cee3f072ae39

📥 Commits

Reviewing files that changed from the base of the PR and between c84fd02 and 12c54d9.

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

Comment thread frontend/src/recommendations.ts
Comment thread frontend/src/recommendations.ts
@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect triaged Item has been triaged labels May 3, 2026
…-set sibling eviction

- pickBestVariantPerCell: fix term=0 clamp from Math.max(1, r.term*12) to
  Math.max(1, r.term||1)*12 so a malformed zero-term rec is treated as 1yr
  (12 months) not 1 month; keeps the effective-savings winner correct
  (CR actionable: 1-month fallback could skew per-cell winner selection)
- checkbox change handler: iterate state.getRecommendations() (full loaded set)
  instead of filtered `recommendations` when evicting same-cell siblings, so
  variants hidden by a column filter are also deselected, preserving the
  one-variant-per-cell contract across filter changes
  (CR actionable: hidden siblings could remain selected, breaking invariant)
- test: update issue-#224 test (a) and (b) to mock state.getRecommendations();
  add new regression test for the hidden-by-filter scenario
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 3, 2026

CR feedback addressed

Actionable fix 1 (minor) — term clamp

  • recommendations.ts pickBestVariantPerCell: changed Math.max(1, r.term * 12) to Math.max(1, r.term || 1) * 12. A malformed rec with term=0 now clamps to 12 months (1yr) not 1 month, keeping the effective-savings winner correct per cell.

Actionable fix 2 (major) — hidden sibling eviction

  • checkbox change handler: replaced iteration over filtered 'recommendations' with iteration over state.getRecommendations() (the full loaded set). This ensures that a variant currently hidden by a column filter (e.g. 1yr filtered out while viewing 3yr) is also deselected when its sibling is checked, preserving the one-variant-per-cell invariant across filter changes.

Tests updated

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

Tip

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

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

  • Fix 1 (term clamp): Math.max(1, r.term || 1) * 12 correctly defaults a malformed term=0 to a 1-year (12-month) amortization window, which is the right sentinel value for the tiebreaker — avoids artificially inflating effective savings for bad data.
  • Fix 2 (hidden sibling eviction): Switching to state.getRecommendations() for sibling scanning is the correct approach to close the invariant gap. Without this, a user could check a 3yr variant while a hidden 1yr variant remained selected, silently breaking the one-variant-per-cell guarantee.

The new regression test for the hidden-sibling scenario is a good addition to lock in this behaviour.

Triggering a fresh review now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 3, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/__tests__/recommendations.test.ts`:
- Around line 379-409: The test fails because the change handler now calls
state.getRecommendationByID(id) but this mock isn't provided; update the test to
mock state.getRecommendationByID to return the corresponding recommendation
object from the full set before dispatching the checkbox change. Specifically,
in this test (and the similar block around lines 2096-2151) add a jest mock on
state.getRecommendationByID that maps the id argument to the correct entry from
the allRecs array (e.g. return allRecs.find(r => r.id === id)) so that
loadRecommendations → checkbox change → state.getRecommendationByID(id) resolves
and the sibling-eviction assertions (state.removeSelectedRecommendation /
state.addSelectedRecommendation) run reliably.
- Around line 2156-2226: The tests for select-all are not seeding
state.getVisibleRecommendations(), so the select-all handler reads an empty list
instead of the mocked recs; update the two tests that call loadRecommendations()
(the tests around the select-all behavior) to mock
state.getVisibleRecommendations to return the same recommendations array you
pass to api.getRecommendations (e.g., set (state.getVisibleRecommendations as
jest.Mock).mockReturnValue(recs) before triggering select-all), ensuring the
select-all handler sees the same candidates as loadRecommendations populated.
🪄 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: 23ac19a0-78f6-4fc0-9486-a431e532bf7c

📥 Commits

Reviewing files that changed from the base of the PR and between 12c54d9 and a89f8c9.

📒 Files selected for processing (2)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/recommendations.ts

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

cristim commented May 3, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 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.

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/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant