Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions CForge/Domain/ProblemFilterEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ struct ProblemFilterEngine {
static func filter(
problems: [Problem],
query: String,
selectedTag: String?
selectedTag: String?,
ratingRange: RatingRange? = nil
) -> [Problem] {

let queryFiltered: [Problem]
Expand All @@ -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)
}
}
}
44 changes: 44 additions & 0 deletions CForge/Domain/RatingRange.swift
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
}
}
}
5 changes: 3 additions & 2 deletions CForge/ViewModels/ProblemListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down
69 changes: 62 additions & 7 deletions CForge/Views/Problem/ProblemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?



Expand Down Expand Up @@ -49,7 +50,14 @@ struct ProblemListView: View {

tagFilterBar
.padding(.bottom, 8)


Text("Rating")
.font(.caption2)
.foregroundColor(.textSecondary)
.padding(.horizontal)
ratingFilterBar
.padding(.bottom, 8)
Comment on lines +58 to +59
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)


ForEach(viewModel.filteredProblems) { problem in
ProblemRow(problem: problem)
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
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
})

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
Expand Down
118 changes: 118 additions & 0 deletions CForgeTests/ProblemFilterEngineRatingRangeTests.swift
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
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"])
}


@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: []
)
}
}