From 3a21066c49501842d52bb78b52fae27d0ca56f8e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 3 May 2026 07:31:12 -0700 Subject: [PATCH 1/2] feat(problem): rating range filter chips for Problem Explorer Closes #6 --- CForge/Domain/ProblemFilterEngine.swift | 26 ++++++-- CForge/Domain/RatingRange.swift | 41 ++++++++++++ CForge/ViewModels/ProblemListViewModel.swift | 5 +- CForge/Views/Problem/ProblemListView.swift | 63 ++++++++++++++++-- .../ProblemFilterEngineRatingRangeTests.swift | 64 +++++++++++++++++++ 5 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 CForge/Domain/RatingRange.swift create mode 100644 CForgeTests/ProblemFilterEngineRatingRangeTests.swift diff --git a/CForge/Domain/ProblemFilterEngine.swift b/CForge/Domain/ProblemFilterEngine.swift index f8883a1..ecf5ca0 100644 --- a/CForge/Domain/ProblemFilterEngine.swift +++ b/CForge/Domain/ProblemFilterEngine.swift @@ -4,7 +4,8 @@ struct ProblemFilterEngine { static func filter( problems: [Problem], query: String, - selectedTag: String? + selectedTag: String?, + ratingRange: RatingRange? = nil ) -> [Problem] { let queryFiltered: [Problem] @@ -27,12 +28,25 @@ struct ProblemFilterEngine { } } - guard let tag = selectedTag else { - return queryFiltered + let tagFiltered: [Problem] + if let tag = selectedTag { + tagFiltered = queryFiltered.filter { problem in + problem.tags.contains(tag) + } + } else { + tagFiltered = queryFiltered } - - return queryFiltered.filter { problem in - problem.tags.contains(tag) + + guard let ratingRange = ratingRange else { + return tagFiltered + } + + return tagFiltered.filter { problem in + guard let rating = problem.rating else { + return false + } + + return ratingRange.contains(rating) } } } diff --git a/CForge/Domain/RatingRange.swift b/CForge/Domain/RatingRange.swift new file mode 100644 index 0000000..c586ecb --- /dev/null +++ b/CForge/Domain/RatingRange.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum RatingRange: String, CaseIterable, Identifiable { + case r800to1000 + case r1000to1200 + case r1200to1400 + case r1400to1800 + case r1800Plus + + public var id: String { rawValue } + + public var label: String { + switch self { + case .r800to1000: + return "800-1000" + case .r1000to1200: + return "1000-1200" + case .r1200to1400: + return "1200-1400" + case .r1400to1800: + return "1400-1800" + case .r1800Plus: + return "1800+" + } + } + + 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 + case .r1800Plus: + return rating >= 1800 + } + } +} diff --git a/CForge/ViewModels/ProblemListViewModel.swift b/CForge/ViewModels/ProblemListViewModel.swift index 15700cb..a9c9451 100644 --- a/CForge/ViewModels/ProblemListViewModel.swift +++ b/CForge/ViewModels/ProblemListViewModel.swift @@ -48,7 +48,7 @@ final class ProblemListViewModel: ObservableObject { } } - func filterProblems(query: String, tag: String?) { + func filterProblems(query: String, tag: String?, ratingRange: RatingRange? = nil) { filterTask?.cancel() let sourceProblems = allProblems @@ -57,7 +57,8 @@ final class ProblemListViewModel: ObservableObject { let filtered = ProblemFilterEngine.filter( problems: sourceProblems, query: query, - selectedTag: tag + selectedTag: tag, + ratingRange: ratingRange ) if Task.isCancelled { return } diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 819c9f8..90ed0ed 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -6,6 +6,7 @@ struct ProblemListView: View { @StateObject private var viewModel = ProblemListViewModel() @State internal var searchText = "" @State internal var selectedTag: String? + @State internal var selectedRatingRange: RatingRange? @@ -49,7 +50,10 @@ struct ProblemListView: View { tagFilterBar .padding(.bottom, 8) - + + ratingFilterBar + .padding(.bottom, 8) + ForEach(viewModel.filteredProblems) { problem in ProblemRow(problem: problem) } @@ -79,12 +83,15 @@ struct ProblemListView: View { .task { await viewModel.loadProblems() } .task(id: searchText) { do { - try await Task.sleep(nanoseconds: 300_000_000) - viewModel.filterProblems(query: searchText, tag: selectedTag) + try await Task.sleep(nanoseconds: 300_000_000) + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) } catch {} } .task(id: selectedTag) { - viewModel.filterProblems(query: searchText, tag: selectedTag) + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) + } + .task(id: selectedRatingRange) { + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) } } } @@ -132,7 +139,7 @@ struct ProblemListView: View { ForEach(Array(Set(problems.flatMap { $0.tags })).sorted(), id: \.self) { tag in Button(action: { selectedTag = selectedTag == tag ? nil : tag - viewModel.filterProblems(query: searchText, tag: selectedTag) + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) }) { Text(tag.capitalized) .font(.caption) @@ -172,6 +179,52 @@ struct ProblemListView: View { } } + 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) + }) { + Text(ratingRange.label) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + ZStack { + if selectedRatingRange == ratingRange { + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + Color.darkerBackground + } + } + ) + .foregroundColor(selectedRatingRange == ratingRange ? .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 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + // MARK: - Problem Row struct ProblemRow: View { let problem: Problem diff --git a/CForgeTests/ProblemFilterEngineRatingRangeTests.swift b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift new file mode 100644 index 0000000..787acd1 --- /dev/null +++ b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift @@ -0,0 +1,64 @@ +import Testing +@testable import CForge + +struct ProblemFilterEngineRatingRangeTests { + + @Test func filterReturnsAllWhenRangeIsNil() { + let problems = [ + makeProblem(id: "rated", rating: 1000), + makeProblem(id: "unrated", rating: nil) + ] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: nil + ) + + #expect(filtered == problems) + } + + @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"]) + } + + private func makeProblem(id: String, rating: Int?) -> Problem { + Problem( + id: id, + contestId: 1, + index: id, + title: "Problem \(id)", + rating: rating, + tags: [] + ) + } +} From caa149d31dbfa25ef7b59fd621be39fbf8d0147d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 8 May 2026 12:10:29 -0700 Subject: [PATCH 2/2] fix(problem): address review feedback on rating range filter Address all inline items from @Sandesh282 plus the coderabbitai boundary-test suggestion on PR #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). --- CForge/Domain/RatingRange.swift | 31 ++++++----- CForge/Views/Problem/ProblemListView.swift | 12 +++-- .../ProblemFilterEngineRatingRangeTests.swift | 54 +++++++++++++++++++ 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/CForge/Domain/RatingRange.swift b/CForge/Domain/RatingRange.swift index c586ecb..7b6ab5b 100644 --- a/CForge/Domain/RatingRange.swift +++ b/CForge/Domain/RatingRange.swift @@ -1,30 +1,31 @@ -import Foundation - -public enum RatingRange: String, CaseIterable, Identifiable { +enum RatingRange: String, CaseIterable, Identifiable { case r800to1000 case r1000to1200 case r1200to1400 - case r1400to1800 + case r1400to1600 + case r1600to1800 case r1800Plus - public var id: String { rawValue } + var id: String { rawValue } - public var label: String { + var label: String { switch self { case .r800to1000: - return "800-1000" + return "800-999" case .r1000to1200: - return "1000-1200" + return "1000-1199" case .r1200to1400: - return "1200-1400" - case .r1400to1800: - return "1400-1800" + return "1200-1399" + case .r1400to1600: + return "1400-1599" + case .r1600to1800: + return "1600-1799" case .r1800Plus: return "1800+" } } - public func contains(_ rating: Int) -> Bool { + func contains(_ rating: Int) -> Bool { switch self { case .r800to1000: return rating >= 800 && rating < 1000 @@ -32,8 +33,10 @@ public enum RatingRange: String, CaseIterable, Identifiable { return rating >= 1000 && rating < 1200 case .r1200to1400: return rating >= 1200 && rating < 1400 - case .r1400to1800: - return rating >= 1400 && rating < 1800 + case .r1400to1600: + return rating >= 1400 && rating < 1600 + case .r1600to1800: + return rating >= 1600 && rating < 1800 case .r1800Plus: return rating >= 1800 } diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 90ed0ed..a859700 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -4,9 +4,9 @@ import WebKit struct ProblemListView: View { @StateObject private var viewModel = ProblemListViewModel() - @State internal var searchText = "" - @State internal var selectedTag: String? - @State internal var selectedRatingRange: RatingRange? + @State private var searchText = "" + @State private var selectedTag: String? + @State private var selectedRatingRange: RatingRange? @@ -51,6 +51,10 @@ struct ProblemListView: View { tagFilterBar .padding(.bottom, 8) + Text("Rating") + .font(.caption2) + .foregroundColor(.textSecondary) + .padding(.horizontal) ratingFilterBar .padding(.bottom, 8) @@ -139,7 +143,6 @@ struct ProblemListView: View { ForEach(Array(Set(problems.flatMap { $0.tags })).sorted(), id: \.self) { tag in Button(action: { selectedTag = selectedTag == tag ? nil : tag - viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) }) { Text(tag.capitalized) .font(.caption) @@ -185,7 +188,6 @@ struct ProblemListView: View { ForEach(RatingRange.allCases) { ratingRange in Button(action: { selectedRatingRange = selectedRatingRange == ratingRange ? nil : ratingRange - viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) }) { Text(ratingRange.label) .font(.caption) diff --git a/CForgeTests/ProblemFilterEngineRatingRangeTests.swift b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift index 787acd1..0de6b28 100644 --- a/CForgeTests/ProblemFilterEngineRatingRangeTests.swift +++ b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift @@ -51,6 +51,60 @@ struct ProblemFilterEngineRatingRangeTests { #expect(filtered.map { $0.id } == ["in-range"]) } + @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"]) + } + + @Test func filterIncludesInclusiveLowerBound() { + let problems = [makeProblem(id: "lower", rating: 1000)] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(filtered.map { $0.id } == ["lower"]) + } + + @Test func filterExcludesUpperBound() { + let problems = [ + makeProblem(id: "upper-excluded", rating: 1200), + makeProblem(id: "upper-included-elsewhere", rating: 1200) + ] + + let inLow = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(inLow.isEmpty) + + let inHigh = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1200to1400 + ) + + #expect(inHigh.count == 2) + } + private func makeProblem(id: String, rating: Int?) -> Problem { Problem( id: id,