Skip to content

feat(problem): rating range filter chips for Problem Explorer#13

Open
mvanhorn wants to merge 2 commits into
Sandesh282:mainfrom
mvanhorn:osc/6-rating-range-filter
Open

feat(problem): rating range filter chips for Problem Explorer#13
mvanhorn wants to merge 2 commits into
Sandesh282:mainfrom
mvanhorn:osc/6-rating-range-filter

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

@mvanhorn mvanhorn commented May 3, 2026

Description

Adds a horizontal row of rating range filter chips to the Problem Explorer, below the existing tag filter bar. The five buckets match the issue: 800-1000, 1000-1200, 1200-1400, 1400-1800, 1800+. Tapping a chip filters the problem list to that range. Tapping again deselects. Combines with the existing search and tag filters. Problems with a nil rating are excluded while a range is active.

The new parameter on ProblemFilterEngine.filter defaults to nil, so all existing call sites are unchanged.

Related Issue

Closes #6

Changes Made

  • New RatingRange enum in CForge/Domain/RatingRange.swift with five cases, a label for chip text, and contains(_:) for the bucket math.
  • ProblemFilterEngine.filter gains an optional ratingRange: parameter and ANDs it after the query and tag filters.
  • ProblemListViewModel.filterProblems threads ratingRange through to the engine.
  • ProblemListView adds a selectedRatingRange state var and a ratingFilterBar chip row that mirrors the tag chip styling (neon gradient on selection, capsule shape, same overlay stroke). All three filters stay in sync via .task(id:).
  • Three Swift Testing cases in CForgeTests/ProblemFilterEngineRatingRangeTests.swift cover the nil, in-range, and out-of-range paths.

Screenshots (if UI changes)

Before After

Will add screenshots before review.

Checklist

  • I have tested my changes on the iOS Simulator
  • The project builds without errors (Cmd+B)
  • My code follows the existing code style
  • I have not committed signing/team configuration changes
  • I have linked the related issue above

Summary by CodeRabbit

  • New Features

    • Added rating-range filtering to the problem list with a selectable rating bar; rating selection combines with search and tag filters to refine results.
  • Tests

    • Expanded test coverage for rating-range behavior, bounds, nil ratings, and interactions between adjacent ranges.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b69ae98-852e-4595-abe3-c20ed9821e6b

📥 Commits

Reviewing files that changed from the base of the PR and between 3a21066 and caa149d.

📒 Files selected for processing (3)
  • CForge/Domain/RatingRange.swift
  • CForge/Views/Problem/ProblemListView.swift
  • CForgeTests/ProblemFilterEngineRatingRangeTests.swift
✅ Files skipped from review due to trivial changes (1)
  • CForgeTests/ProblemFilterEngineRatingRangeTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • CForge/Views/Problem/ProblemListView.swift

📝 Walkthrough

Walkthrough

Adds a RatingRange enum and threads optional rating-range filtering through ProblemFilterEngine, ProblemListViewModel, and ProblemListView; UI gains rating chips and state; tests validate in-range, boundary, and nil-rating behaviors.

Changes

Rating Range Filter Integration

Layer / File(s) Summary
Data Model
CForge/Domain/RatingRange.swift
Adds internal RatingRange enum with six buckets, id and label properties, and contains(_:) for membership checks.
Core Filtering Logic
CForge/Domain/ProblemFilterEngine.swift
filter signature updated to accept optional ratingRange; introduces tagFiltered intermediate and conditionally applies rating filtering that excludes nil ratings when a range is active.
ViewModel Wiring
CForge/ViewModels/ProblemListViewModel.swift
filterProblems(query:tag:ratingRange:) added and passes ratingRange into ProblemFilterEngine.filter inside the detached async task.
UI Integration & State
CForge/Views/Problem/ProblemListView.swift
Adds @State selectedRatingRange, ratingFilterBar rendering chips for RatingRange.allCases, and updates .task modifiers and tag-button actions to include ratingRange.
Tests & Validation
CForgeTests/ProblemFilterEngineRatingRangeTests.swift
Expanded test suite covering nil-range passthrough, dropping nil ratings when active, in-range inclusion, inclusive lower bounds, exclusive upper bounds, and .r1800Plus behavior; includes makeProblem helper.

Sequence Diagram

sequenceDiagram
    participant User
    participant ProblemListView as ProblemListView
    participant ViewModel as ProblemListViewModel
    participant FilterEngine as ProblemFilterEngine
    participant Results as Filtered Results

    User->>ProblemListView: Select rating range chip
    Note over ProblemListView: selectedRatingRange updated
    ProblemListView->>ProblemListView: .task(id: selectedRatingRange) triggered
    ProblemListView->>ViewModel: filterProblems(query, tag, ratingRange)
    ViewModel->>ViewModel: Detached async task starts
    ViewModel->>FilterEngine: filter(problems, query, selectedTag, ratingRange)
    Note over FilterEngine: 1. Apply text query filter<br/>2. Apply tag filter (if selectedTag)<br/>3. Apply rating filter (if ratingRange)
    FilterEngine->>Results: [Problem] (filtered)
    Results-->>ViewModel: Return filtered problems
    ViewModel->>ViewModel: Update `@Published` filteredProblems
    ViewModel-->>ProblemListView: UI re-renders with results
    ProblemListView->>User: Display filtered problems
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰
I hopped through enums and filter chains,
Six rating chips to ease the pains.
Tap, toggle, and the list refines,
Problems sorted by difficulty lines.
Happy practicing—carrots and commits!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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
Title check ✅ Passed The title clearly and concisely summarizes the primary change: adding rating range filter chips to the Problem Explorer UI.
Description check ✅ Passed The description covers all template sections with sufficient detail: it explains what the PR does, links to issue #6, lists all key changes, and includes a complete checklist. Screenshots are noted as pending, which is acceptable.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #6: adds RatingRange enum with correct buckets [#6], integrates rating filtering into ProblemFilterEngine [#6], updates ProblemListViewModel [#6], adds chip UI to ProblemListView [#6], and includes tests covering nil/in-range/out-of-range cases [#6].
Out of Scope Changes check ✅ Passed All changes are within scope: the new RatingRange enum, filter engine updates, view model threading, UI chips, and tests are all directly required by issue #6. No extraneous modifications detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

🧹 Nitpick comments (2)
CForgeTests/ProblemFilterEngineRatingRangeTests.swift (1)

38-52: ⚡ Quick win

Add boundary-value tests to cover the half-open interval edges.

The three existing tests cover the happy path but skip the boundary values that are most likely to regress if the contains(_:) logic changes (e.g., >= vs >). At minimum, verify the inclusive lower bound and exclusive upper bound for one representative range.

✅ Suggested boundary test
`@Test` func filterRespectsBoundaries() {
    let problems = [
        makeProblem(id: "lower-bound",  rating: 1000),  // inclusive lower — must be kept
        makeProblem(id: "within",       rating: 1100),
        makeProblem(id: "upper-bound",  rating: 1199),  // last included value
        makeProblem(id: "just-above",   rating: 1200)   // exclusive upper — must be dropped
    ]

    let filtered = ProblemFilterEngine.filter(
        problems: problems,
        query: "",
        selectedTag: nil,
        ratingRange: .r1000to1200
    )

    `#expect`(filtered.map { $0.id } == ["lower-bound", "within", "upper-bound"])
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CForgeTests/ProblemFilterEngineRatingRangeTests.swift` around lines 38 - 52,
Add a new unit test to cover boundary values for the half-open rating range used
by ProblemFilterEngine.filter: create a `@Test` func (e.g.,
filterRespectsBoundaries) that builds problems with ratings 1000 (inclusive
lower), 1100 (within), 1199 (last included) and 1200 (exclusive upper), call
ProblemFilterEngine.filter(problems:query:selectedTag:ratingRange:) with
ratingRange: .r1000to1200, and assert the filtered IDs equal
["lower-bound","within","upper-bound"]; use the existing makeProblem helper and
same test file so the test exercises the inclusive lower and exclusive upper
behavior.
CForge/Views/Problem/ProblemListView.swift (1)

182-226: ⚡ Quick win

Extract a shared chip ViewBuilder to eliminate duplication with tagFilterBar.

ratingFilterBar is ~40 lines that reproduce the exact same background gradient, cornerRadius, overlay, and stroke modifier chain from tagFilterBar. Any style change (e.g., corner radius, gradient opacity) now needs updating in two places.

♻️ Proposed refactor — shared chip helper
+    `@ViewBuilder`
+    private func filterChip(label: String, isSelected: Bool) -> some View {
+        Text(label)
+            .font(.caption)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 6)
+            .background(
+                ZStack {
+                    if isSelected {
+                        LinearGradient(
+                            colors: [.neonBlue, .neonPurple],
+                            startPoint: .topLeading,
+                            endPoint: .bottomTrailing
+                        )
+                    } else {
+                        Color.darkerBackground
+                    }
+                }
+            )
+            .foregroundColor(isSelected ? .white : .primary)
+            .cornerRadius(12)
+            .overlay(
+                RoundedRectangle(cornerRadius: 12)
+                    .stroke(
+                        LinearGradient(
+                            colors: [.neonBlue.opacity(0.4), .neonPurple.opacity(0.4)],
+                            startPoint: .topLeading,
+                            endPoint: .bottomTrailing
+                        ),
+                        lineWidth: 1
+                    )
+            )
+    }

Then in ratingFilterBar:

-                        Text(ratingRange.label)
-                            .font(.caption)
-                            .padding(.horizontal, 12)
-                            // ... ~25 lines of styling ...
+                        filterChip(label: ratingRange.label,
+                                   isSelected: selectedRatingRange == ratingRange)

And similarly in tagFilterBar:

-                        Text(tag.capitalized)
-                            .font(.caption)
-                            .padding(.horizontal, 12)
-                            // ... ~25 lines of styling ...
+                        filterChip(label: tag.capitalized,
+                                   isSelected: selectedTag == tag)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CForge/Views/Problem/ProblemListView.swift` around lines 182 - 226, The
ratingFilterBar duplicates the same chip styling chain used in tagFilterBar;
extract a reusable ViewBuilder (e.g., a private func chip<V: View>(_ label:
String, selected: Bool, content: () -> V) -> some View or a private var/closure
buildChip) that applies the shared background gradient, cornerRadius(12),
overlay stroke, padding, font, foregroundColor logic and buttonStyle(.plain),
then replace the per-item modifier chains in ratingFilterBar and tagFilterBar to
call this helper (use RatingRange.label and selectedRatingRange in
ratingFilterBar and the tag label/selection in tagFilterBar) so all visual
styling is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CForge/Views/Problem/ProblemListView.swift`:
- Line 9: The `@State` property selectedRatingRange (and the related `@State` vars
searchText and selectedTag) are flagged by SwiftLint for not being private;
either mark these properties as private (e.g., change the declarations of
selectedRatingRange, searchText, and selectedTag to private) or, if they are
intentionally internal for tests via `@testable` import, add an explicit comment
above each declaration documenting that intent and suppressing the lint rule
(e.g., a one-line comment referencing private_swiftui_state and test access) so
the linter won’t warn; locate these in the ProblemListView
(RatingRange/selectedRatingRange) and apply one consistent approach.

---

Nitpick comments:
In `@CForge/Views/Problem/ProblemListView.swift`:
- Around line 182-226: The ratingFilterBar duplicates the same chip styling
chain used in tagFilterBar; extract a reusable ViewBuilder (e.g., a private func
chip<V: View>(_ label: String, selected: Bool, content: () -> V) -> some View or
a private var/closure buildChip) that applies the shared background gradient,
cornerRadius(12), overlay stroke, padding, font, foregroundColor logic and
buttonStyle(.plain), then replace the per-item modifier chains in
ratingFilterBar and tagFilterBar to call this helper (use RatingRange.label and
selectedRatingRange in ratingFilterBar and the tag label/selection in
tagFilterBar) so all visual styling is centralized.

In `@CForgeTests/ProblemFilterEngineRatingRangeTests.swift`:
- Around line 38-52: Add a new unit test to cover boundary values for the
half-open rating range used by ProblemFilterEngine.filter: create a `@Test` func
(e.g., filterRespectsBoundaries) that builds problems with ratings 1000
(inclusive lower), 1100 (within), 1199 (last included) and 1200 (exclusive
upper), call ProblemFilterEngine.filter(problems:query:selectedTag:ratingRange:)
with ratingRange: .r1000to1200, and assert the filtered IDs equal
["lower-bound","within","upper-bound"]; use the existing makeProblem helper and
same test file so the test exercises the inclusive lower and exclusive upper
behavior.
🪄 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: 857a1b1a-07f6-43de-bd37-7edd6f3d3987

📥 Commits

Reviewing files that changed from the base of the PR and between d744e41 and 3a21066.

📒 Files selected for processing (5)
  • CForge/Domain/ProblemFilterEngine.swift
  • CForge/Domain/RatingRange.swift
  • CForge/ViewModels/ProblemListViewModel.swift
  • CForge/Views/Problem/ProblemListView.swift
  • CForgeTests/ProblemFilterEngineRatingRangeTests.swift

Comment thread CForge/Views/Problem/ProblemListView.swift Outdated
@mvanhorn
Copy link
Copy Markdown
Contributor Author

mvanhorn commented May 3, 2026

Heads up: I didn't run this on a Simulator locally. The diff is small and the three Swift Testing cases for the engine cover the new code path, but you'll need to do manual QA on the chip row before merging. If anything looks off in the build, ping me and I'll iterate.

Comment thread CForge/Domain/RatingRange.swift Outdated
Comment on lines +3 to +10
public enum RatingRange: String, CaseIterable, Identifiable {
case r800to1000
case r1000to1200
case r1200to1400
case r1400to1800
case r1800Plus

public var id: String { rawValue }
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nothing in this codebase uses public. All other types are internal (default access). @testable import CForge already gives the test target full internal access, so public buys you nothing here. Please remove it from the enum, id, label, and contains.

Comment thread CForge/Domain/RatingRange.swift Outdated
Comment on lines +12 to +17
public var label: String {
switch self {
case .r800to1000:
return "800-1000"
case .r1000to1200:
return "1000-1200"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The labels are misleading. contains() uses rating < 1000 (exclusive upper bound), so a problem rated exactly 1000 falls into r1000to1200, not r800to1000. A user looking at the chip "800-1000" would expect 1000 to be included.

Fix the labels to reflect the actual ranges:

case .r800to1000:  return "800–999"
case .r1000to1200: return "1000–1199"
case .r1200to1400: return "1200–1399"
case .r1400to1800: return "1400–1799"
case .r1800Plus:   return "1800+"

Comment thread CForge/Domain/RatingRange.swift Outdated
Comment on lines +27 to +36
public func contains(_ rating: Int) -> Bool {
switch self {
case .r800to1000:
return rating >= 800 && rating < 1000
case .r1000to1200:
return rating >= 1000 && rating < 1200
case .r1200to1400:
return rating >= 1200 && rating < 1400
case .r1400to1800:
return rating >= 1400 && rating < 1800
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Quick suggestion: The 1400–1800 bucket is 400 points wide, double every other bucket (200 pts each). This is the range where most active competitive programmers sit. Consider splitting into 1400–1600 and 1600–1800 for better granularity. Not a blocker, but worth discussing.

Comment thread CForge/Domain/RatingRange.swift Outdated
Comment on lines +1 to +3
import Foundation

public enum RatingRange: String, CaseIterable, Identifiable {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

import Foundation is unused here. RatingRange only uses Swift stdlib types (String, Bool, Int). Safe to remove.

Comment on lines +22 to +52
@Test func filterDropsNilRatedProblemsWhenRangeIsActive() {
let problems = [
makeProblem(id: "rated", rating: 1100),
makeProblem(id: "unrated", rating: nil)
]

let filtered = ProblemFilterEngine.filter(
problems: problems,
query: "",
selectedTag: nil,
ratingRange: .r1000to1200
)

#expect(filtered.map { $0.id } == ["rated"])
}

@Test func filterKeepsProblemsInRange() {
let problems = [
makeProblem(id: "in-range", rating: 1100),
makeProblem(id: "out-of-range", rating: 800)
]

let filtered = ProblemFilterEngine.filter(
problems: problems,
query: "",
selectedTag: nil,
ratingRange: .r1000to1200
)

#expect(filtered.map { $0.id } == ["in-range"])
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Good coverage of the main cases, but the r1800Plus open-ended range has no test. There's no ceiling: a problem rated 3500 should still match. Please add:

@Test func filterIncludesHighRatingsInR1800Plus() {
    let problems = [
        makeProblem(id: "gm-level", rating: 3500),
        makeProblem(id: "below", rating: 1799)
    ]
    let filtered = ProblemFilterEngine.filter(
        problems: problems, query: "", selectedTag: nil, ratingRange: .r1800Plus
    )
    #expect(filtered.map { $0.id } == ["gm-level"])
}

Comment on lines +182 to +189
private var ratingFilterBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(RatingRange.allCases) { ratingRange in
Button(action: {
selectedRatingRange = selectedRatingRange == ratingRange ? nil : ratingRange
viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange)
}) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

There's a double-trigger here.
The button action calls filterProblems(...) directly, and then the .task(id: selectedRatingRange) modifier below also fires when selectedRatingRange changes, so filtering runs twice per tap. The same issue exists in tagFilterBar on main, so this is consistent, but it's worth fixing in both.

The clean fix is to remove the explicit filterProblems(...) call from all button action closures and let the .task(id:) modifiers be the single source of truth:

Button(action: {
    selectedRatingRange = selectedRatingRange == ratingRange ? nil : ratingRange
    // ← remove filterProblems call here, .task(id: selectedRatingRange) handles it
})

Comment on lines +54 to +55
ratingFilterBar
.padding(.bottom, 8)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

There are now two unlabeled chip rows back-to-back. First-time users won't know which row filters tags and which filters ratings. Consider adding a small section label:

Text("Rating")
    .font(.caption2)
    .foregroundColor(.textSecondary)
    .padding(.horizontal)
ratingFilterBar
    .padding(.bottom, 8)

Copy link
Copy Markdown
Owner

@Sandesh282 Sandesh282 left a comment

Choose a reason for hiding this comment

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

@mvanhorn Solid implementation overall. I have taken an initial pass. PTAL

Address all inline items from @Sandesh282 plus the coderabbitai
boundary-test suggestion on PR Sandesh282#13:

RatingRange.swift:
- Drop unused `import Foundation`.
- Remove `public` from the enum and members; codebase is internal-default
  and `@testable import CForge` already covers the test target.
- Fix labels so they reflect the half-open intervals enforced by
  `contains` (800-999, 1000-1199, 1200-1399, 1400-1599, 1600-1799, 1800+).
- Split the wider 1400-1800 bucket into r1400to1600 and r1600to1800 to
  match the 200-point granularity of the other buckets.

ProblemListView.swift:
- Switch the three filter `@State` properties from `internal` to `private`
  (existing tests drive `ProblemFilterEngine.filter` directly, not the
  view state, so private is safe).
- Add a "Rating" caption above the rating chip row so first-time users
  can tell the two unlabeled chip rows apart.
- Drop the explicit `filterProblems(...)` calls from the tag and rating
  chip button actions; the `.task(id:)` modifiers already fire when the
  bindings change, so the explicit calls double-triggered filtering.

ProblemFilterEngineRatingRangeTests.swift:
- Add `filterIncludesHighRatingsInR1800Plus` covering the open-ended
  upper range with a 3500-rated problem.
- Add `filterIncludesInclusiveLowerBound` and `filterExcludesUpperBound`
  for the half-open interval boundary semantics (coderabbitai nit).
@mvanhorn
Copy link
Copy Markdown
Contributor Author

mvanhorn commented May 8, 2026

@Sandesh282 addressed all 8 inline items + the coderabbitai boundary-test nit in caa149d.

RatingRange.swift

  • removed unused import Foundation
  • removed public from enum / id / label / contains
  • fixed labels to match half-open semantics: 800-999, 1000-1199, 1200-1399, 1400-1599, 1600-1799, 1800+
  • split the 1400-1800 bucket into r1400to1600 and r1600to1800 (200pt each, matching the rest)

ProblemListView.swift

  • @State internal var -> @State private var for searchText/selectedTag/selectedRatingRange (existing tests drive ProblemFilterEngine.filter directly, not the view state, so private is safe)
  • added the "Rating" caption above the rating chip row
  • removed the explicit filterProblems(...) from the tag and rating chip button actions; the .task(id:) modifiers already cover this so it was double-triggering

Tests

  • added filterIncludesHighRatingsInR1800Plus (rating 3500)
  • added filterIncludesInclusiveLowerBound and filterExcludesUpperBound for the half-open boundary cases

Verified swift -frontend -parse clean across all changed files; no remaining r1400to1800 references in the tree. xcodebuild + simulator are still on you for the manual QA pass.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Add rating range filter to the Problem Explorer

2 participants