-
Notifications
You must be signed in to change notification settings - Fork 2
feat(problem): rating range filter chips for Problem Explorer #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| enum RatingRange: String, CaseIterable, Identifiable { | ||
| case r800to1000 | ||
| case r1000to1200 | ||
| case r1200to1400 | ||
| case r1400to1600 | ||
| case r1600to1800 | ||
| case r1800Plus | ||
|
|
||
| var id: String { rawValue } | ||
|
|
||
| var label: String { | ||
| switch self { | ||
| case .r800to1000: | ||
| return "800-999" | ||
| case .r1000to1200: | ||
| return "1000-1199" | ||
| case .r1200to1400: | ||
| return "1200-1399" | ||
| case .r1400to1600: | ||
| return "1400-1599" | ||
| case .r1600to1800: | ||
| return "1600-1799" | ||
| case .r1800Plus: | ||
| return "1800+" | ||
| } | ||
| } | ||
|
|
||
| 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 .r1400to1600: | ||
| return rating >= 1400 && rating < 1600 | ||
| case .r1600to1800: | ||
| return rating >= 1600 && rating < 1800 | ||
| case .r1800Plus: | ||
| return rating >= 1800 | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,8 +4,9 @@ import WebKit | |
| struct ProblemListView: View { | ||
|
|
||
| @StateObject private var viewModel = ProblemListViewModel() | ||
| @State internal var searchText = "" | ||
| @State internal var selectedTag: String? | ||
| @State private var searchText = "" | ||
| @State private var selectedTag: String? | ||
| @State private var selectedRatingRange: RatingRange? | ||
|
|
||
|
|
||
|
|
||
|
|
@@ -49,7 +50,14 @@ struct ProblemListView: View { | |
|
|
||
| tagFilterBar | ||
| .padding(.bottom, 8) | ||
|
|
||
|
|
||
| Text("Rating") | ||
| .font(.caption2) | ||
| .foregroundColor(.textSecondary) | ||
| .padding(.horizontal) | ||
| ratingFilterBar | ||
| .padding(.bottom, 8) | ||
|
|
||
| ForEach(viewModel.filteredProblems) { problem in | ||
| ProblemRow(problem: problem) | ||
| } | ||
|
|
@@ -79,12 +87,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 +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) | ||
| }) { | ||
| Text(tag.capitalized) | ||
| .font(.caption) | ||
|
|
@@ -172,6 +182,51 @@ 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 | ||
| }) { | ||
|
Comment on lines
+185
to
+191
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a double-trigger here. The clean fix is to remove the explicit Button(action: {
selectedRatingRange = selectedRatingRange == ratingRange ? nil : ratingRange
// ← remove filterProblems call here, .task(id: selectedRatingRange) handles it
}) |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| 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"]) | ||
| } | ||
|
Comment on lines
+22
to
+52
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good coverage of the main cases, but the @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 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, | ||
| contestId: 1, | ||
| index: id, | ||
| title: "Problem \(id)", | ||
| rating: rating, | ||
| tags: [] | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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: