fix(ux/opportunities): deterministic sort on Term/Payment/Cost/Effective % cells - #496
Conversation
…ive % cells The previous `groupsInSortOrder` collapsed every multi-variant cell to the same score via `Math.max(...va.map(numericKey))`: after PR #195's per-(term, payment) fan-out every cell has BOTH 1yr and 3yr variants, so `Math.max` always = 3 for the `term` column; cells with any null monthly_cost or null pct collapse to `POSITIVE_INFINITY`; cells with all-no-upfront variants tie at 0 for `upfront_cost`. Every cell tying produces apparently-random row order (Array.prototype.sort stability is implementation-defined). Extracts `cellScoreFor(column, variants, selectedRecs)` and replaces the collapse with per-column scores that match what the user sees in the rendered summary row: * term: `summary.termMin * 100 + summary.termMax` (encodes both ends so 1yr-only sorts before mixed sorts before 3yr-only) * upfront_cost: `summary.upfrontMin` (matches the rendered `formatSavingsRange(upfrontMin, upfrontMax)`) * monthly_cost: `min` over non-null variants; POSITIVE_INFINITY only when ALL variants are null * effective_savings_pct: `max` over non-null variants; same null rule * payment (categorical): `PAYMENT_ORDER` index of the first variant in `sortVariantsInCell` order; replaces the alphabetic comparator (which placed `all-upfront` before `no-upfront`, semantically wrong) Selected-variant cells (user clicked a radio inside the cell) keep using the selected variant's value, but on the SAME numeric scale as the non-selected path for `term` so the two are directly comparable (otherwise a selected `term=1` cell would rank below a non-selected mixed (1, 3) cell when it should rank above). For other numeric columns (savings, count, on_demand_monthly) the established `Math.max` semantics are preserved. For the string columns that aren't payment (provider, account, service, region, resource_type) the value is invariant across a cell's variants by definition of cellKey, so the first variant suffices. Adds a stable `cellKey.localeCompare` tiebreaker (direction-invariant) so genuinely-tied cells render in a deterministic order across renders and across browsers. 10 new regression tests in `recommendations.test.ts` cover: * Term ascending: 1yr-only cell < mixed cell < 3yr-only cell * Term descending: exact reverse * Payment ascending: no-upfront < partial-upfront < all-upfront * Upfront Cost ascending: sorted by upfrontMin * Monthly Cost ascending: sorted by `Math.min` over non-null variants * Monthly Cost: all-null cells sort last in BOTH asc and desc * Effective % ascending: sorted by `Math.max` over non-null variants * Effective %: all-null cells sort last in both directions * Selected-variant interaction: selected term=1 cell sorts above non-selected mixed (1, 3) cell when ascending * Determinism: repeating the same sort twice produces identical row order for all 5 affected columns Closes #494
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR implements deterministic per-cell per-column scoring ( ChangesDeterministic Cell Sorting
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
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 4910-4918: The sort assertions use
renderedCellOrder().findIndex(...) directly which can return -1 and produce
false-positive comparisons; update each test (e.g., the block using
renderedCellOrder and keys like 'bbb-1y-only', 'aaa-mixed', 'ccc-3y-only') to
first assert each expected key exists (e.g., expect(order.some(k =>
k.includes('...'))).toBeTrue()/toBeTruthy() or use a shared helper like
assertIndexExists(order, key) that throws a clear error if missing), then
perform the index comparisons using order.findIndex(...) so the tests fail
deterministically when a key is absent; apply the same pattern to the other
occurrences listed in the comment.
In `@frontend/src/recommendations.ts`:
- Around line 804-815: In the numeric-comparison branch (where scoreA/scoreB are
numbers) handle the case where both scores are Number.POSITIVE_INFINITY before
computing and returning the numeric diff: add an explicit check (e.g. if (scoreA
=== Number.POSITIVE_INFINITY && scoreB === Number.POSITIVE_INFINITY) { /* treat
as equal so fallback tie-breaker runs */ }) so you don't compute (Infinity -
Infinity) which yields NaN and causes an early return; leave the logic to fall
through to the cellKey fallback tie-breaker used elsewhere in this comparator.
🪄 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: 8c749c7c-6135-4f7c-8ce3-1a3772a24a22
📒 Files selected for processing (2)
frontend/src/__tests__/recommendations.test.tsfrontend/src/recommendations.ts
CodeRabbit flagged two issues on the initial commit: 1. **Major / production**: `(Infinity - Infinity) === NaN` in the numeric-diff branch of `groupsInSortOrder`. The `if (diff !== 0) return diff` short-circuit returns NaN (because `NaN !== 0`), so when both cells score POSITIVE_INFINITY (all-null monthly_cost or all-null effective_savings_pct on both sides) the cellKey tiebreaker is bypassed and the rendered order becomes implementation-defined. Guard the numeric diff with `if (!aNullish)` so both-nullish pairs fall through to the cellKey localeCompare tiebreaker. 2. **Minor / tests**: `order.findIndex(...)` returning `-1` would silently satisfy `<` comparisons and pass tests even when a key is missing. Extracted an `indexOrFail(order, fragment)` helper that asserts presence before returning the index; converted all 36 ordering assertions in the new #494 describe block to use it. Also adds one regression test (`two all-null cells sort deterministically via cellKey tiebreaker`) that exercises the both-POSITIVE_INFINITY path and asserts the cellKey-based fallback order is stable across repeated invocations. Per-file scope: * `frontend/src/recommendations.ts`: 5 lines added (guard + comment), 2 lines removed. * `frontend/src/__tests__/recommendations.test.ts`: indexOrFail helper + 36 call-site rewrites + 1 new regression test. Refs #494, #496 CR review.
|
Addressed both CR findings in c0f2440:
Frontend Jest suite: 1753 pass / 1 skip / 0 fail across 54 suites (was 1752 pre-CR-fix). @coderabbitai review |
|
Triggering a review of the new commit now. ✅ Actions performedReview triggered.
|
Summary
Fixes #494 — on the Opportunities table, clicking the sort headers for
Term/Payment/Upfront Cost/Monthly Cost/Effective %produced apparently-random row order becauseMath.max(...va.map(numericKey))collapsed every multi-variant cell to the same score after PR #195's per-(term, payment) fan-out (every cell has both 1yr and 3yr variants → always max=3 forterm; any null monthly_cost / pct → POSITIVE_INFINITY; cells with all-no-upfront variants → tie at 0).PR #491 (closes #480) flipped the default direction for these columns from
desctoasc; this PR fixes the upstream "every cell ties" symptom. The two PRs touch independent code paths (#491's diff against the same base doesn't modifygroupsInSortOrder).What changed
cellScoreFor(column, variants, selectedRecs)helper infrontend/src/recommendations.tsthat returns ONE deterministic per-cell score per column:term→summary.termMin * 100 + summary.termMax(encodes both ends; 1yr-only < mixed < 3yr-only)upfront_cost→summary.upfrontMin(matches rendered range)monthly_cost→Math.minover non-null variants;POSITIVE_INFINITYonly when ALL variants are nulleffective_savings_pct→Math.maxover non-null variants; same null rulepayment(categorical) →PAYMENT_ORDERindex of the first variant insortVariantsInCellorder (replaces alphabetic comparator that placedall-upfrontbeforeno-upfront)term, otherwise a selectedterm=1cell would sort below a non-selected mixed(1, 3)cell when ascending).cellKey.localeComparetiebreaker (direction-invariant) for genuinely-tied cells so renders are deterministic across reloads and browsers.Math.maxsemantics —#494does not flag them.va[0]— the value is invariant across a cell's variants by definition ofcellKey.Regression coverage
10 new tests in
frontend/src/__tests__/recommendations.test.tsexercise multi-variant fixtures (the bug only manifests post per-(term, payment) fan-out):upfrontMinMath.minover non-null variantsMath.maxover non-null variantsterm=1ranks above non-selected mixed(1, 3)Test plan
npm test— 1752 pass / 1 skip / 0 fail (54 suites; was 1742 pre-PR + 10 new regression tests)npx tsc --noEmit— cleannpm run build— production bundle compiles cleanly (no new warnings)recommendations.tsandrecommendations.test.ts(per project convention)Out of scope
on_demand_monthlycolumn — same root cause technically applies but is not flagged in the issue body's QA rows (4.9-4.15); leaving unchanged here to keep blast radius tight. If the same pattern matters there too, filing a follow-up after merge per CLAUDE.md §1.Closes #494
Summary by CodeRabbit
Bug Fixes
Tests