Skip to content

fix(frontend/recs): Potential Monthly Savings card mirrors page-level range (closes #272)#276

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/recs-savings-card-272
May 5, 2026
Merged

fix(frontend/recs): Potential Monthly Savings card mirrors page-level range (closes #272)#276
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/recs-savings-card-272

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 4, 2026

Summary

Closes #272. Aligns the Potential Monthly Savings stats card with
the Recommended range banner under the table.

Before: card showed summary.total_monthly_savings from the API
— a flat sum across every (term, payment) variant of every cell. On
the 33-rec dev cache the card read $576 while the banner correctly
read $235 – $344/mo across 12 cells. The user can only buy one
variant per cell, so the card overstated achievable savings by ~75-500%
depending on the variant fan-out per cell.

After: the card is computed client-side from
pageLevelRange(groupRecsByCell(recommendations)) — exactly the same
source the banner uses. Renders as a range via the existing
formatSavingsRange helper, which collapses "$X – $X" to "$X" for
single-variant cells (so single-cell pages still show one value).

Changes

  • frontend/src/recommendations.ts:158::renderRecommendationsSummary
    now takes recommendations and recomputes the savings figure from
    the same per-cell min/max sum the banner uses.
  • summary.total_monthly_savings is no longer read by this view; the
    backend field is left in place for callers outside this view.

Test plan

  • New test Potential Monthly Savings card mirrors page-level range, not the API sum (#272) — builds 2 cells × 2 variants where
    the API sum is $700 but achievable is $300 – $400, asserts the card
    shows the range and never the inflated total.
  • npm test -- --testPathPattern=recommendations — 141 / 141
    pass.
  • npm run typecheck — clean.

🤖 Generated with claude-flow

Summary by CodeRabbit

  • Bug Fixes

    • Fixed "Potential Monthly Savings" display in the summary card to show page-level savings range computed from per-cell variants instead of API total.
  • Tests

    • Added test coverage for savings range calculation in recommendations.

… range (closes #272)

The stats card and the "Recommended range" banner under the table were
showing different numbers for the same data, in a way that overstated
achievable savings:

- Card: `summary.total_monthly_savings` from the API — a flat sum
  across **every variant of every cell**.
- Banner: `pageLevelRange(groups).savingsMin/Max` — sums per-cell
  min/max savings (one variant per cell).

A typical 12-cell page with 2 terms × 3 payment options per cell
returns up to 72 variant rows. The user can only buy ONE variant per
cell, so summing all variants overstates achievable monthly savings by
roughly 6× on a fully-fanned-out page (and ~75% even on smaller
2-cell × 2-variant cases). On the 33-rec dev cache the card showed
$576 while the banner correctly showed $235 – $344/mo across 12 cells.

Fix in `renderRecommendationsSummary` (recommendations.ts:158): compute
the savings card from `pageLevelRange(groupRecsByCell(recommendations))`
using the same source the banner already uses. Render via the existing
`formatSavingsRange` helper, which collapses "$X – $X" to "$X" when min
and max coincide (single-variant cells) — visually consistent with the
banner.

Empty-data path: when `cellCount === 0` or `savingsMax === 0`, the
card renders `$0` (matches the previous empty behaviour).

`summary.total_monthly_savings` is no longer read by the card; the
backend field is left in place for now (consumers may exist outside
this view), but the new comment in the function flags the next reader
to recompute client-side from the same source rather than reuse the
flat sum.

New test `Potential Monthly Savings card mirrors page-level range, not
the API sum (#272)` builds 2 cells × 2 variants where the API sum is
$700 but achievable is $300 – $400, and asserts the card shows the
range and never the inflated total.
@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/xs Trivial / one-liner type/bug Defect triaged Item has been triaged labels May 4, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 4, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 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: 58f3aac2-a673-4b38-b715-93332cf4f058

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

The PR fixes issue #272 by modifying the "Potential Monthly Savings" summary card to compute a page-level savings range from per-cell variant grouping, rather than summing all API variants (which caused double-counting). The implementation passes recommendations data to the summary renderer, which now calculates the range using existing helper functions instead of the API's flat total.

Changes

Savings Range Calculation Fix

Layer / File(s) Summary
Data Flow
frontend/src/recommendations.ts:138-143
loadRecommendations() now passes the loaded recommendations list alongside the API summary to renderRecommendationsSummary(...).
Core Logic
frontend/src/recommendations.ts:158-188
renderRecommendationsSummary(summary, recommendations) replaces direct API total_monthly_savings usage with per-cell grouping (groupRecsByCell) and pageLevelRange computation, rendering the range via formatSavingsRange instead of a single inflated sum.
Tests
frontend/src/__tests__/recommendations.test.ts:134-167
New loadRecommendations test verifies the card displays the computed range ($300–$400) and excludes the API's incorrect total ($700), directly validating issue #272 fix.

Suggested labels

effort/s

Possibly related PRs

  • LeanerCloud/CUDly#253: Introduces and refines the same grouping/savings helper functions (groupRecsByCell, pageLevelRange, formatSavingsRange) that this PR now uses for the summary card calculation.
  • LeanerCloud/CUDly#231: Establishes per-physical-cell variant selection logic (cellKey, pickBestVariantPerCell) that provides the conceptual foundation for this PR's per-cell grouping approach to computing savings.
  • LeanerCloud/CUDly#243: Also modifies loadRecommendations() and the data flow into summary rendering, representing related work on recommendation data handling.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes


🐰 A card once summed every way,
Per variant, every day,
Now grouped by cell with care,
The range is honest, fair!
No more counting (times three)! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.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 title clearly summarizes the main change: updating the Potential Monthly Savings card to use page-level range instead of API sum, which directly addresses issue #272.
Linked Issues check ✅ Passed The PR meets all acceptance criteria from #272: card now mirrors banner via pageLevelRange computation, client-side recomputation prevents divergence, uses same range formatting, and adds regression test.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #272: test and recommendations.ts modifications compute savings consistently between card and banner without introducing unrelated alterations.

✏️ 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 fix/recs-savings-card-272

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 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: 1

🤖 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/recommendations.ts`:
- Around line 141-142: The summary card is built from the original loaded set
but the banner uses the filtered visible set, so keep a cached initial summary
(e.g., store data.summary into a module-level variable like initialSummary on
load) and then, inside the function that rerenders the list
(renderRecommendationsList or its caller), recompute or derive the card values
by combining that cached initialSummary with the current visible recommendations
(the filtered array passed into renderRecommendationsList) and call
renderRecommendationsSummary with that recomputed summary; update both the
initial load path (where you set initialSummary from data.summary) and the
list-rerender path so the card is recalculated every time filters change (also
apply same change for the other occurrence around lines 173-177).
🪄 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: 56c81ca0-9382-4166-9fe8-746305e25336

📥 Commits

Reviewing files that changed from the base of the PR and between e010987 and 534bac7.

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

Comment thread frontend/src/recommendations.ts Outdated
Comment on lines 141 to 142
renderRecommendationsSummary(data.summary || {}, data.recommendations || []);
renderRecommendationsList(data.recommendations || []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Summary card can still diverge from the banner after filters are applied

At Line 141, renderRecommendationsSummary(...) is fed the full loaded set (data.recommendations), but the banner range is computed from the filtered visible set in renderRecommendationsList (Line 1555 path). With active column filters, the two values diverge again.

A practical fix is to cache the latest summary once on load and recompute the card using the current visible recommendations on every list rerender.

Suggested direction
+let lastRecommendationsSummary: RecommendationsSummary = {};

 export async function loadRecommendations(): Promise<void> {
   ...
-  renderRecommendationsSummary(data.summary || {}, data.recommendations || []);
-  renderRecommendationsList(data.recommendations || []);
+  lastRecommendationsSummary = data.summary || {};
+  renderRecommendationsList(data.recommendations || []);
+  const visible = state.getVisibleRecommendations() as unknown as LocalRecommendation[];
+  renderRecommendationsSummary(lastRecommendationsSummary, visible);
   ...
 }

 function rerenderRecommendations(): void {
   const loaded = state.getRecommendations() as unknown as LocalRecommendation[];
   renderRecommendationsList(loaded);
+  const visible = state.getVisibleRecommendations() as unknown as LocalRecommendation[];
+  renderRecommendationsSummary(lastRecommendationsSummary, visible);
 }

Also applies to: 173-177

🤖 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 141 - 142, The summary card is
built from the original loaded set but the banner uses the filtered visible set,
so keep a cached initial summary (e.g., store data.summary into a module-level
variable like initialSummary on load) and then, inside the function that
rerenders the list (renderRecommendationsList or its caller), recompute or
derive the card values by combining that cached initialSummary with the current
visible recommendations (the filtered array passed into
renderRecommendationsList) and call renderRecommendationsSummary with that
recomputed summary; update both the initial load path (where you set
initialSummary from data.summary) and the list-rerender path so the card is
recalculated every time filters change (also apply same change for the other
occurrence around lines 173-177).

…set (#272 CR)

The first pass of #272 fed renderRecommendationsSummary the full loaded
set on initial render, but the per-cell range banner under the table
uses the post-filter visible set computed inside renderRecommendationsList.
With an active column filter, the card and the banner diverged again —
the same bug class #272 was supposed to close.

CR caught this on the original PR; the fix moves the savings-card
recompute into renderRecommendationsList so every entry to the list
render (initial load, sort-header click, column-filter commit,
Clear-filters badge, popover commit) recomputes the card from the
*post-filter* set.

Mechanics:
- New module-level `lastRecommendationsSummary` caches the API-derived
  summary. loadRecommendations writes to it once on each successful
  load; renderRecommendationsList reads it on every entry.
- renderRecommendationsList now calls renderRecommendationsSummary
  with the post-applyColumnFilters array (the same one used for the
  banner range and the table body).
- rerenderRecommendations no longer needs to call the summary
  separately — every path it uses goes through renderRecommendationsList,
  which handles the recompute.

Tests:
- Existing `Potential Monthly Savings card mirrors page-level range
  (#272)` updated to mock state.getVisibleRecommendations so the
  initial-render path's new visible-set source is observable.
- New `savings card recomputes from visible set on filter change
  (#272 CR follow-up)` builds 4 recs / 2 cells, applies a real
  service=ec2 column-filter mock, triggers a sort-header rerender,
  and asserts the card narrows from "$300 – $400" (loaded set) to
  "$100 – $150" (cell1 only).

Test isolation: the new test explicitly resets
state.getRecommendationsColumnFilters at the end. jest.clearAllMocks
resets `.mock.calls` but NOT `.mockReturnValue`, so a leaked filter
value would otherwise narrow the rec set in subsequent tests
(observed: select-all-checkbox tests dropped from 2 → 1 selected
rec because the leaked ec2 filter excluded the rds rec).
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

Addressed CR feedback. See follow-up commit. @coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Tip

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

@cristim Sure! I'll kick off a fresh review of the follow-up commit now.

[review]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate 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