Skip to content

feat(recs): plumb provider SavingsPercentage to GUI for CLI/GUI parity (closes #1106) - #1104

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/effective-savings-pct-amortization
Jun 8, 2026
Merged

feat(recs): plumb provider SavingsPercentage to GUI for CLI/GUI parity (closes #1106)#1104
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/effective-savings-pct-amortization

Conversation

@cristim

@cristim cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member

CLI/GUI parity for the savings %

Note: the original double-amortization fix (effectiveSavingsPct / effectiveMonthlySavings) that this PR also carried is now redundant. A smaller PR, #1110, landed the functionally equivalent fix on the base branch and closed #1103. This branch has been rebased onto the updated base; its amortization commit was dropped (base already has it). What remains here is the provider-authoritative savings_percentage parity work, which closes only #1106.

The CLI/reporter prints AWS's authoritative rec.SavingsPercentage directly (internal/reporter/reporter.go, sourced from AWS EstimatedMonthlySavingsPercentage in parser_ri.go; Azure/GCP converters also set SavingsPercentage). But SavingsPercentage was dropped at the API boundary, so the GUI re-derived the % client-side via effectiveSavingsPct. That recomputation (a) could drift from the provider's authoritative number, and (b) returned null / em-dash when on_demand_cost was missing even though the provider gave a real % (the #323 case). The CLI never had these problems because it used the provider value directly.

The fix (full-stack)

  • internal/config/types.go: added nullable RecommendationRecord.SavingsPercentage *float64. Persisted via the recommendations row's JSONB payload column, no DDL/migration needed (same as OnDemandCost). nil = provider did not report a %; a provider 0 round-trips as nil, never a fabricated pointer-to-0.
  • internal/scheduler/scheduler.go: in convertRecommendations (the only builder mapping a common rec to a record) set SavingsPercentage: nonZeroPtr(rec.SavingsPercentage), parity with OnDemandCost.
  • frontend/src/types.ts: added savings_percentage?: number | null to LocalRecommendation.
  • frontend/src/recommendations.ts: added displaySavingsPct(r) which prefers a finite, non-null r.savings_percentage and falls back to the (net-savings) effectiveSavingsPct. Routed the effective-% column render, the sort accessor, the per-cell variant aggregation, the numeric filter accessor, and the purchase-modal row render through it. effectiveSavingsPct / effectiveMonthlySavings are unchanged (fallback path).

Net effect: the GUI shows the identical % the CLI shows, and AWS recs missing on_demand_cost now render the provider % instead of an em-dash.

Tests (fail pre-change)

  • Go: TestScheduler_ConvertRecommendations_SavingsPercentage asserts the value is carried (non-nil when present, nil when 0/absent).
  • TS: displaySavingsPct uses the provider % verbatim and ignores the computed value; falls back when null/undefined/non-finite; an AWS rec with a provider % but on_demand_cost=null renders the % instead of an em-dash.

Verification (post-rebase)

  • go build ./... clean; go vet ./internal/config/... ./internal/scheduler/... clean; go test ./internal/config/... ./internal/scheduler/... -> 663 pass.
  • frontend/: npx jest src/__tests__/recommendations.test.ts -> 386 pass, 0 fail; npx tsc --noEmit clean.
  • Changed Go files gofmt-clean.

Closes #1106
Related: #323, #1035

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect labels Jun 7, 2026
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

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 35 minutes and 32 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: ec060e04-83f0-4fde-8361-5c8380f5bdd9

📥 Commits

Reviewing files that changed from the base of the PR and between e31ed68 and 1fa33c0.

📒 Files selected for processing (6)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
  • frontend/src/types.ts
  • internal/config/types.go
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_test.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/effective-savings-pct-amortization

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

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

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

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Scope expanded with the CLI/GUI savings-% parity fix (new commit feat(recs): plumb provider SavingsPercentage to GUI for CLI/GUI parity):

  • Backend struct internal/config/types.go: added nullable RecommendationRecord.SavingsPercentage *float64 (JSONB payload, no DDL; nil = not reported).
  • Mapping internal/scheduler/scheduler.go: convertRecommendations now sets SavingsPercentage: nonZeroPtr(rec.SavingsPercentage) (the only common-rec -> record builder; parity with OnDemandCost).
  • Frontend type frontend/src/types.ts: added savings_percentage?: number | null.
  • Frontend display/sort frontend/src/recommendations.ts: new displaySavingsPct(r) prefers the provider % and falls back to effectiveSavingsPct; wired into the column render, sort accessor, per-cell variant aggregation, numeric filter accessor, and purchase-modal row.
  • Tests: Go TestScheduler_ConvertRecommendations_SavingsPercentage; TS displaySavingsPct parity cases (provider % verbatim, fallback when null/undefined/non-finite, AWS missing on_demand_cost shows the % not an em-dash). All confirmed failing pre-change.

Verification: go build/vet/test (659 pass), jest (388 pass), tsc clean, gofmt clean. Also closes #1106.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim: Noted — I'll review the expanded scope including the new feat(recs): plumb provider SavingsPercentage to GUI for CLI/GUI parity commit covering the full-stack parity fix (RecommendationRecord.SavingsPercentage, nonZeroPtr mapping in convertRecommendations, savings_percentage on the frontend type, and displaySavingsPct routing).


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:45:38.069Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/cloudsql/client.go`, `getSQLPricing` had the same per-hour/term-total unit mismatch as memorystore and cloudstorage: `commitmentPrice` (per-hour SKU rate) was passed directly to savings math that expected a term total, producing ~99.99% savings, and `HourlyRate` was set to `per-hour / hoursInTerm` (near-zero). Fixed in PR `#1047` commit 20590c6b4 (issue `#1078` folded in): `commitmentPriceTerm := commitmentPrice * hoursInTerm`; `HourlyRate = commitmentPrice` (raw per-hour). `convertGCPRecommendation` also hardcoded `rec.Term = "1yr"`, ignoring `params.Term`; fixed with same defaulting pattern as memorystore/cloudstorage. Regression tests: `TestGetSQLPricing_CommitmentPriceIsTermTotal` and `TestCloudSQLConvertGCPRecommendation_PropagatesTermFromParams`. PaymentOption was already correct (`if paymentOption == "" { paymentOption = "monthly" }`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly GCP service converters (`convertGCPRecommendation` in memorystore/client.go and cloudstorage/client.go), `rec.Term` must be derived from `params.Term` with a `"1yr"` default — not hardcoded to `"1yr"`. Without this, 3-year callers always emit 1-year commitments even though the downstream `termYears` derivation from `rec.Term` is correct. Fixed in PR `#1047` commit c6280c390 (F2 for memorystore, F4 for cloudstorage). Regression tests: `TestConvertGCPRecommendation_PropagatesTermFromParams` and `TestCloudStorageConvertGCPRecommendation_PropagatesTermFromParams`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:28:28.139Z
Learning: In LeanerCloud/CUDly `providers/gcp/recommendations.go`, `collectRegion` fans out to all four GCP service clients (computeengine, cloudsql, memorystore, cloudstorage) concurrently (fixed in PR `#1047` commit 95a390f, H-2 finding). `regionResult` gains `cache` and `storage` slices; merge is updated accordingly. The decision was to wire (not trim) memorystore and cloudstorage because both have complete `GetRecommendations` implementations and their no-op `PurchaseCommitment` paths are orthogonal to surfacing advisory recommendations. Regression tests: `TestRegionResult_HasCacheAndStorageFields` and `TestShouldIncludeService_Cache_Storage`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` must force `paymentOption = "monthly"` unconditionally (logging a WARN when a non-monthly value such as "upfront", "all-upfront", or "partial-upfront" is supplied) because GCP CUDs are monthly-only and any non-monthly value passed through would be a silent misconfiguration. Fixed in PR `#1047` commit c6280c390 (F1). Regression test: `TestConvertGCPRecommendation_NonMonthlyPaymentOptionForcedToMonthly`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:13:37.445Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` no longer hardcodes `Term = "1yr"` (fixed in PR `#1047` commit 8f9a787, H-3 audit finding). It propagates `params.Term` with a `"1yr"` default, validates via `termPlan`, and returns nil on unknown term. This is stricter than memorystore/cloudstorage/cloudsql (which default and continue): computeengine returns nil on unknown term because it is on the purchase path. Regression tests: `TestConvertGCPRecommendation_PropagatesParamsTerm`, `TestConvertGCPRecommendation_RejectsUnknownTerm`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:28:28.139Z
Learning: In LeanerCloud/CUDly GCP service clients (computeengine, cloudsql, memorystore, cloudstorage), `GetRecommendations` filters out non-ACTIVE recommendations using the typed `recommenderpb.RecommendationStateInfo_ACTIVE` enum (CLAIMED/SUCCEEDED/FAILED/DISMISSED are skipped). State is accessed nil-safely via the proto getter. Fixed in PR `#1047` commit 95a390f (H-1 finding). Regression tests: `TestGetRecommendations_FiltersNonActiveStates` (5-rec mock, only ACTIVE survives) and `TestGetRecommendations_ActiveRecIncluded`. Existing mock tests in all four services had StateInfo added to satisfy the new filter.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:13:37.445Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, the `memMBPerVCPU = 4096` const was removed in PR `#1047` commit 8f9a787. MEMORY MB is now extracted directly from the Recommender payload via `memoryMBFromOperationGroups` (sibling of `vcpuCountFromOperationGroups`). `buildInsertRequest` and `GroupCommitments` call `memoryMBFromDetails(rec)` which returns an error (no silent fallback) when `Details.MemoryGB` is absent. This ensures high-memory families (e.g. N2-highmem) get the correct MB from the payload rather than the GENERAL_PURPOSE approximation. Regression test: `TestBuildInsertRequest_RefusesMissingMemory`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 339
File: providers/aws/recommendations/coverage.go:177-205
Timestamp: 2026-05-13T21:30:43.093Z
Learning: In the CUDly project (LeanerCloud/CUDly), the CLI flag for target-based coverage sizing is `--target-coverage` (not `--target-utilization`). The PR renamed it during design iteration: "utilization" (how full each RI is) stays ~100% under the new sizing, while "coverage" (% of demand covered) is the user-facing knob. `ce:GetReservationCoverage` is required only when `--target-coverage` is set, gated by `cfg.TargetCoverage <= 0` in `cmd/multi_service.go`. The legacy `--coverage` path is unaffected and pays no CE cost or IAM change.
✅ Action performed

Review finished.

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 commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: I'll kick off a new review pass covering the latest changes.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 339
File: providers/aws/recommendations/coverage.go:177-205
Timestamp: 2026-05-13T21:30:43.093Z
Learning: In the CUDly project (LeanerCloud/CUDly), the CLI flag for target-based coverage sizing is `--target-coverage` (not `--target-utilization`). The PR renamed it during design iteration: "utilization" (how full each RI is) stays ~100% under the new sizing, while "coverage" (% of demand covered) is the user-facing knob. `ce:GetReservationCoverage` is required only when `--target-coverage` is set, gated by `cfg.TargetCoverage <= 0` in `cmd/multi_service.go`. The legacy `--coverage` path is unaffected and pays no CE cost or IAM change.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly GCP service converters (`convertGCPRecommendation` in memorystore/client.go and cloudstorage/client.go), `rec.Term` must be derived from `params.Term` with a `"1yr"` default — not hardcoded to `"1yr"`. Without this, 3-year callers always emit 1-year commitments even though the downstream `termYears` derivation from `rec.Term` is correct. Fixed in PR `#1047` commit c6280c390 (F2 for memorystore, F4 for cloudstorage). Regression tests: `TestConvertGCPRecommendation_PropagatesTermFromParams` and `TestCloudStorageConvertGCPRecommendation_PropagatesTermFromParams`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:28:28.139Z
Learning: In LeanerCloud/CUDly `providers/gcp/recommendations.go`, `collectRegion` fans out to all four GCP service clients (computeengine, cloudsql, memorystore, cloudstorage) concurrently (fixed in PR `#1047` commit 95a390f, H-2 finding). `regionResult` gains `cache` and `storage` slices; merge is updated accordingly. The decision was to wire (not trim) memorystore and cloudstorage because both have complete `GetRecommendations` implementations and their no-op `PurchaseCommitment` paths are orthogonal to surfacing advisory recommendations. Regression tests: `TestRegionResult_HasCacheAndStorageFields` and `TestShouldIncludeService_Cache_Storage`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:45:38.069Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/cloudsql/client.go`, `getSQLPricing` had the same per-hour/term-total unit mismatch as memorystore and cloudstorage: `commitmentPrice` (per-hour SKU rate) was passed directly to savings math that expected a term total, producing ~99.99% savings, and `HourlyRate` was set to `per-hour / hoursInTerm` (near-zero). Fixed in PR `#1047` commit 20590c6b4 (issue `#1078` folded in): `commitmentPriceTerm := commitmentPrice * hoursInTerm`; `HourlyRate = commitmentPrice` (raw per-hour). `convertGCPRecommendation` also hardcoded `rec.Term = "1yr"`, ignoring `params.Term`; fixed with same defaulting pattern as memorystore/cloudstorage. Regression tests: `TestGetSQLPricing_CommitmentPriceIsTermTotal` and `TestCloudSQLConvertGCPRecommendation_PropagatesTermFromParams`. PaymentOption was already correct (`if paymentOption == "" { paymentOption = "monthly" }`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` must force `paymentOption = "monthly"` unconditionally (logging a WARN when a non-monthly value such as "upfront", "all-upfront", or "partial-upfront" is supplied) because GCP CUDs are monthly-only and any non-monthly value passed through would be a silent misconfiguration. Fixed in PR `#1047` commit c6280c390 (F1). Regression test: `TestConvertGCPRecommendation_NonMonthlyPaymentOptionForcedToMonthly`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:28:28.139Z
Learning: In LeanerCloud/CUDly GCP service clients (computeengine, cloudsql, memorystore, cloudstorage), `GetRecommendations` filters out non-ACTIVE recommendations using the typed `recommenderpb.RecommendationStateInfo_ACTIVE` enum (CLAIMED/SUCCEEDED/FAILED/DISMISSED are skipped). State is accessed nil-safely via the proto getter. Fixed in PR `#1047` commit 95a390f (H-1 finding). Regression tests: `TestGetRecommendations_FiltersNonActiveStates` (5-rec mock, only ACTIVE survives) and `TestGetRecommendations_ActiveRecIncluded`. Existing mock tests in all four services had StateInfo added to satisfy the new filter.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:13:37.445Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` no longer hardcodes `Term = "1yr"` (fixed in PR `#1047` commit 8f9a787, H-3 audit finding). It propagates `params.Term` with a `"1yr"` default, validates via `termPlan`, and returns nil on unknown term. This is stricter than memorystore/cloudstorage/cloudsql (which default and continue): computeengine returns nil on unknown term because it is on the purchase path. Regression tests: `TestConvertGCPRecommendation_PropagatesParamsTerm`, `TestConvertGCPRecommendation_RejectsUnknownTerm`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:13:37.445Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, the `memMBPerVCPU = 4096` const was removed in PR `#1047` commit 8f9a787. MEMORY MB is now extracted directly from the Recommender payload via `memoryMBFromOperationGroups` (sibling of `vcpuCountFromOperationGroups`). `buildInsertRequest` and `GroupCommitments` call `memoryMBFromDetails(rec)` which returns an error (no silent fallback) when `Details.MemoryGB` is absent. This ensures high-memory families (e.g. N2-highmem) get the correct MB from the payload rather than the GENERAL_PURPOSE approximation. Regression test: `TestBuildInsertRequest_RefusesMissingMemory`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.
✅ Action performed

Review finished.

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.

The CLI/reporter prints AWS's authoritative SavingsPercentage
(EstimatedMonthlySavingsPercentage; Azure/GCP converters also set it)
directly, but the field was dropped at the API boundary, so the GUI had to
re-derive the % via effectiveSavingsPct. That recomputation could drift from
the provider's number and returned a blank em-dash for AWS recs missing on_demand_cost
even when the provider reported a real % (the #323 case).

Plumb SavingsPercentage end-to-end so the GUI shows the same number the CLI
shows:

- internal/config/types.go: add nullable RecommendationRecord.SavingsPercentage
  (*float64). Persisted via the recommendations row's JSONB payload column, no
  DDL needed (same as OnDemandCost). nil = provider did not report a %.
- internal/scheduler/scheduler.go: in convertRecommendations (the only builder
  that maps a common rec to a RecommendationRecord) set
  SavingsPercentage: nonZeroPtr(rec.SavingsPercentage), mirroring OnDemandCost.
  A provider value of 0 round-trips as nil, never a fabricated pointer-to-0.
- frontend/src/types.ts: add savings_percentage?: number | null to
  LocalRecommendation (provider-authoritative %, preferred over reconstruction).
- frontend/src/recommendations.ts: add displaySavingsPct(r) which prefers a
  finite, non-null r.savings_percentage and falls back to the (net-savings)
  effectiveSavingsPct. Route the effective-% column render, the sort accessor,
  the per-cell variant aggregation, the numeric filter accessor, and the
  purchase-modal row render through it. effectiveSavingsPct /
  effectiveMonthlySavings are unchanged (fallback path).

Net effect: GUI shows the identical % the CLI shows, and AWS recs missing
on_demand_cost now render the provider % instead of an em-dash.

Tests (fail pre-change):
- Go: TestScheduler_ConvertRecommendations_SavingsPercentage asserts the value
  is carried (non-nil when present, nil when 0/absent).
- TS: displaySavingsPct uses the provider % verbatim and ignores the computed
  value; falls back to effectiveSavingsPct when null/undefined/non-finite; an
  AWS rec with a provider % but on_demand_cost=null now renders the % instead
  of an em-dash.

Also closes #1106
@cristim
cristim force-pushed the fix/effective-savings-pct-amortization branch from a6f4069 to 1fa33c0 Compare June 8, 2026 04:24
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Rebased onto the latest feat/multicloud-web-frontend and resolved the duplicate-work conflict with #1110.

What changed in the rebase:

Remaining (parity) work, one commit on top of base:

  • internal/config/types.go: nullable RecommendationRecord.SavingsPercentage *float64 (JSONB payload, no DDL).
  • internal/scheduler/scheduler.go: convertRecommendations sets SavingsPercentage: nonZeroPtr(rec.SavingsPercentage).
  • frontend/src/types.ts: savings_percentage?: number | null on LocalRecommendation.
  • frontend/src/recommendations.ts: new displaySavingsPct(r) (prefers provider %, falls back to effectiveSavingsPct), routed through all 5 display/sort/filter/variant/modal call-sites.

Verification (post-rebase): go build ./..., go vet + go test ./internal/config/... ./internal/scheduler/... -> 663 pass (incl. the new TestScheduler_ConvertRecommendations_SavingsPercentage); frontend/ npx jest src/__tests__/recommendations.test.ts -> 386 pass, npx tsc --noEmit clean. Changed Go files gofmt-clean.

@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 changed the title fix(recs): stop double-amortizing upfront in effectiveSavingsPct (AWS negative %) feat(recs): plumb provider SavingsPercentage to GUI for CLI/GUI parity (closes #1106) Jun 8, 2026
@cristim
cristim merged commit 763f4ae into feat/multicloud-web-frontend Jun 8, 2026
5 checks passed
@cristim
cristim deleted the fix/effective-savings-pct-amortization branch July 27, 2026 11:10
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/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