From c5e1f4b356e021290652b68b48529f7f60b5b5a8 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Mon, 1 Apr 2024 13:54:22 +0200 Subject: [PATCH] add seeding system --- PadelClub.xcodeproj/project.pbxproj | 4 + PadelClub/Data/GroupStage.swift | 2 +- PadelClub/Data/Match.swift | 130 +++++++++++++++-- PadelClub/Data/PlayerRegistration.swift | 4 + PadelClub/Data/Round.swift | 20 ++- PadelClub/Data/TeamRegistration.swift | 46 ++++++ PadelClub/Data/TeamScore.swift | 3 + PadelClub/Data/Tournament.swift | 131 +++++++++++++++--- PadelClub/Manager/PadelRule.swift | 17 +++ .../ViewModel/TournamentSeedEditing.swift | 29 ++++ .../Views/GroupStage/GroupStageView.swift | 2 +- PadelClub/Views/Match/MatchRowView.swift | 6 +- PadelClub/Views/Match/MatchSetupView.swift | 54 ++++++-- PadelClub/Views/Round/RoundSettingsView.swift | 111 ++++++++++++++- PadelClub/Views/Round/RoundView.swift | 4 +- PadelClub/Views/Round/RoundsView.swift | 7 +- PadelClub/Views/Team/TeamPickerView.swift | 60 +++++++- PadelClub/Views/Team/TeamRowView.swift | 24 +++- .../Components/InscriptionInfoView.swift | 6 +- .../Screen/InscriptionManagerView.swift | 2 +- 20 files changed, 600 insertions(+), 62 deletions(-) create mode 100644 PadelClub/ViewModel/TournamentSeedEditing.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 585f260..0b319c2 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -116,6 +116,7 @@ FF5DA18F2BB9268800A33061 /* GroupStageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5DA18E2BB9268800A33061 /* GroupStageSettingsView.swift */; }; FF5DA1932BB9279B00A33061 /* RoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5DA1922BB9279B00A33061 /* RoundSettingsView.swift */; }; FF5DA1952BB927E800A33061 /* GenericDestinationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5DA1942BB927E800A33061 /* GenericDestinationPickerView.swift */; }; + FF5DA19B2BB9662200A33061 /* TournamentSeedEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5DA19A2BB9662200A33061 /* TournamentSeedEditing.swift */; }; FF6EC8F72B94773200EA7F5A /* RowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */; }; FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */; }; FF6EC8FE2B94792300EA7F5A /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FD2B94792300EA7F5A /* Screen.swift */; }; @@ -343,6 +344,7 @@ FF5DA18E2BB9268800A33061 /* GroupStageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStageSettingsView.swift; sourceTree = ""; }; FF5DA1922BB9279B00A33061 /* RoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundSettingsView.swift; sourceTree = ""; }; FF5DA1942BB927E800A33061 /* GenericDestinationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericDestinationPickerView.swift; sourceTree = ""; }; + FF5DA19A2BB9662200A33061 /* TournamentSeedEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentSeedEditing.swift; sourceTree = ""; }; FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowButtonView.swift; sourceTree = ""; }; FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentButtonView.swift; sourceTree = ""; }; FF6EC8FD2B94792300EA7F5A /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; @@ -775,6 +777,7 @@ FF3F74FE2B91A2D4004CFE0E /* AgendaDestination.swift */, FF4AB6BA2B9256D50002987F /* SearchViewModel.swift */, FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */, + FF5DA19A2BB9662200A33061 /* TournamentSeedEditing.swift */, ); path = ViewModel; sourceTree = ""; @@ -1168,6 +1171,7 @@ FF8F263B2BAD528600650388 /* EventCreationView.swift in Sources */, FFC1E1082BAC29FC008D6F59 /* LocationManager.swift in Sources */, FFA1B1292BB71773006CE248 /* PadelClubButtonView.swift in Sources */, + FF5DA19B2BB9662200A33061 /* TournamentSeedEditing.swift in Sources */, FF70916C2B91005400AB08DA /* TournamentView.swift in Sources */, FF1DC5552BAB36DD00FD8220 /* CreateClubView.swift in Sources */, FFC1E10A2BAC2A77008D6F59 /* NetworkFederalService.swift in Sources */, diff --git a/PadelClub/Data/GroupStage.swift b/PadelClub/Data/GroupStage.swift index 96cd026..b6715da 100644 --- a/PadelClub/Data/GroupStage.swift +++ b/PadelClub/Data/GroupStage.swift @@ -21,7 +21,7 @@ class GroupStage: ModelObject, Storable { var matchFormat: MatchFormat { get { - MatchFormat(rawValue: format ?? 0) ?? .defaultFormatForMatchType(.groupStage) + MatchFormat(rawValue: format) ?? .defaultFormatForMatchType(.groupStage) } set { format = newValue.rawValue diff --git a/PadelClub/Data/Match.swift b/PadelClub/Data/Match.swift index 36c47fc..77f402b 100644 --- a/PadelClub/Data/Match.swift +++ b/PadelClub/Data/Match.swift @@ -26,6 +26,7 @@ class Match: ModelObject, Storable { var broadcasted: Bool var name: String? var order: Int + private(set) var disabled: Bool = false internal init(round: String? = nil, groupStage: String? = nil, startDate: Date? = nil, endDate: Date? = nil, index: Int, matchFormat: MatchFormat? = nil, court: String? = nil, servingTeamId: String? = nil, winningTeamId: String? = nil, losingTeamId: String? = nil, broadcasted: Bool = false, name: String? = nil, order: Int = 0) { self.round = round @@ -46,6 +47,8 @@ class Match: ModelObject, Storable { func indexInRound() -> Int { if groupStage != nil { return index + } else if let index = roundObject?.matches.firstIndex(where: { $0.id == id }) { + return index } return RoundRule.matchIndexWithinRound(fromMatchIndex: index) } @@ -58,26 +61,63 @@ class Match: ModelObject, Storable { return "#\(indexInRound() + 1)" } } - - func topPreviousRoundMatches() -> Int { + + func disableMatch() { + _toggleMatchDisableState(true) + } + + func enableMatch() { + _toggleMatchDisableState(false) + } + + fileprivate func _toggleMatchDisableState(_ state: Bool) { + disabled = state + topPreviousRoundMatch()?._toggleMatchDisableState(state) + bottomPreviousRoundMatch()?._toggleMatchDisableState(state) + try? DataStore.shared.matches.addOrUpdate(instance: self) + } + + func topPreviousRoundMatchIndex() -> Int { index * 2 + 1 } - func bottomPreviousRoundMatches() -> Int { + func bottomPreviousRoundMatchIndex() -> Int { (index + 1) * 2 } + func topPreviousRoundMatch() -> Match? { + guard let roundObject else { return nil } + return Store.main.filter { match in + match.index == topPreviousRoundMatchIndex() && match.round == roundObject.previousRound()?.id + }.sorted(by: \.index).first + } + + func bottomPreviousRoundMatch() -> Match? { + guard let roundObject else { return nil } + return Store.main.filter { match in + match.index == bottomPreviousRoundMatchIndex() && match.round == roundObject.previousRound()?.id + }.sorted(by: \.index).first + } + + func previousMatch(_ teamPosition: Int) -> Match? { + if teamPosition == 0 { + return topPreviousRoundMatch() + } else { + return bottomPreviousRoundMatch() + } + } + func previousMatches() -> [Match] { guard let roundObject else { return [] } return Store.main.filter { match in - (match.index == topPreviousRoundMatches() || match.index == bottomPreviousRoundMatches()) + (match.index == topPreviousRoundMatchIndex() || match.index == bottomPreviousRoundMatchIndex()) && match.round == roundObject.previousRound()?.id }.sorted(by: \.index) } var matchFormat: MatchFormat { get { - MatchFormat(rawValue: format ?? 0) ?? .defaultFormatForMatchType(.groupStage) + MatchFormat(rawValue: format) ?? .defaultFormatForMatchType(.groupStage) } set { format = newValue.rawValue @@ -100,6 +140,10 @@ class Match: ModelObject, Storable { groupStage != nil } + func isBracket() -> Bool { + round != nil + } + func isTournamentMatch() -> Bool { groupStageObject?.tournament != nil } @@ -113,7 +157,7 @@ class Match: ModelObject, Storable { } func currentTournament() -> Tournament? { - groupStageObject?.tournamentObject() + groupStageObject?.tournamentObject() ?? roundObject?.tournamentObject() } func scores() -> [TeamScore] { @@ -121,7 +165,57 @@ class Match: ModelObject, Storable { } func teams() -> [TeamRegistration] { - scores().compactMap({ $0.team }).sorted(by: \.computedPosition) + if groupStage != nil { + return scores().compactMap({ $0.team }).sorted(by: \.groupStagePosition!) + } + return [roundProjectedTeam(.one), roundProjectedTeam(.two)].compactMap { $0 } + } + + func groupStageProjectedTeam(_ team: TeamData) -> TeamRegistration? { + guard groupStage != nil else { return nil } + + switch team { + case .one: + if let teamId = topPreviousRoundMatch()?.winningTeamId { + return Store.main.findById(teamId) + } + case .two: + if let teamId = bottomPreviousRoundMatch()?.winningTeamId { + return Store.main.findById(teamId) + } + } + + return nil + } + + func seed(_ team: TeamData) -> TeamRegistration? { + guard let roundObject else { return nil } + return Store.main.filter(isIncluded: { + $0.tournament == roundObject.tournament && $0.bracketPosition != nil + }).first(where: { + ($0.bracketPosition! / 2) == self.index + && ($0.bracketPosition! % 2) == team.rawValue + }) + } + + func roundProjectedTeam(_ team: TeamData) -> TeamRegistration? { + guard round != nil else { return nil } + if let seed = seed(team) { + return seed + } + + switch team { + case .one: + if let teamId = topPreviousRoundMatch()?.winningTeamId { + return Store.main.findById(teamId) + } + case .two: + if let teamId = bottomPreviousRoundMatch()?.winningTeamId { + return Store.main.findById(teamId) + } + } + + return nil } func teamWon(_ team: TeamData) -> Bool { @@ -129,16 +223,25 @@ class Match: ModelObject, Storable { } func team(_ team: TeamData) -> TeamRegistration? { - switch team { - case .one: - teams().first - case .two: - teams().last + if groupStage != nil { + switch team { + case .one: + return teams().first + case .two: + return teams().last + } + } else { + switch team { + case .one: + return roundProjectedTeam(.one) + case .two: + return roundProjectedTeam(.two) + } } } func teamNames(_ team: TeamData) -> [String]? { - self.team(team)?.players().map { $0.lastName } + self.team(team)?.players().map { $0.playerLabel() } } func teamWalkOut(_ team: TeamData) -> Bool { @@ -207,5 +310,6 @@ class Match: ModelObject, Storable { case _broadcasted = "broadcasted" case _name = "name" case _order = "order" + case _disabled = "disabled" } } diff --git a/PadelClub/Data/PlayerRegistration.swift b/PadelClub/Data/PlayerRegistration.swift index 01fc8f6..1295306 100644 --- a/PadelClub/Data/PlayerRegistration.swift +++ b/PadelClub/Data/PlayerRegistration.swift @@ -89,6 +89,10 @@ class PlayerRegistration: ModelObject, Storable { [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: " ") } + func isPlaying() -> Bool { + team()?.isPlaying() == true + } + func contains(_ searchField: String) -> Bool { firstName.localizedCaseInsensitiveContains(searchField) || lastName.localizedCaseInsensitiveContains(searchField) } diff --git a/PadelClub/Data/Round.swift b/PadelClub/Data/Round.swift index 05f93aa..8240cae 100644 --- a/PadelClub/Data/Round.swift +++ b/PadelClub/Data/Round.swift @@ -18,13 +18,23 @@ class Round: ModelObject, Storable { var loser: String? var format: Int? - internal init(tournament: String, index: Int, loser: String? = nil, format: Int? = nil) { + internal init(tournament: String, index: Int, loser: String? = nil, matchFormat: MatchFormat? = nil) { self.tournament = tournament self.index = index self.loser = loser - self.format = format + self.format = matchFormat?.rawValue + } + + var matchFormat: MatchFormat { + get { + MatchFormat(rawValue: format) ?? .defaultFormatForMatchType(.bracket) + } + set { + format = newValue.rawValue + } } + func hasStarted() -> Bool { matches.anySatisfy({ $0.hasStarted() }) } @@ -32,9 +42,13 @@ class Round: ModelObject, Storable { func hasEnded() -> Bool { matches.allSatisfy({ $0.hasEnded() }) } + + func tournamentObject() -> Tournament? { + Store.main.findById(tournament) + } var matches: [Match] { - Store.main.filter { $0.round == self.id } + Store.main.filter { $0.round == self.id && $0.disabled == false } } func previousRound() -> Round? { diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index 04fcc1e..bace11c 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -48,6 +48,23 @@ class TeamRegistration: ModelObject, Storable { self.category = category } + func isSeedable() -> Bool { + bracketPosition == nil && groupStage == nil + } + + func setSeedPosition(inSpot match: Match, upperBranch: Int?, opposingSeeding: Bool) { + let matchIndex = match.index + let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound) + let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2) + var teamPosition = upperBranch ?? (isUpper ? 0 : 1) + if opposingSeeding { + teamPosition = upperBranch ?? (isUpper ? 1 : 0) + } + match.previousMatch(teamPosition)?.disableMatch() + bracketPosition = matchIndex * 2 + teamPosition + } + var initialWeight: Int { lockWeight ?? weight } @@ -68,6 +85,18 @@ class TeamRegistration: ModelObject, Storable { wildCardBracket || wildCardGroupStage } + func isPlaying() -> Bool { + currentMatch() != nil + } + + func currentMatch() -> Match? { + teamScores().compactMap { $0.matchObject() }.first(where: { $0.isRunning() }) + } + + func teamScores() -> [TeamScore] { + Store.main.filter(isIncluded: { $0.teamRegistration == id }) + } + var tournamentCategory: TournamentCategory { get { TournamentCategory(rawValue: category ?? 0) ?? .men @@ -134,6 +163,18 @@ class TeamRegistration: ModelObject, Storable { groupStagePosition ?? -1 } + func available() -> Bool { + groupStage == nil && bracketPosition == nil + } + + func inGroupStage() -> Bool { + groupStagePosition != nil + } + + func inRound() -> Bool { + bracketPosition != nil + } + func resetPositions() { groupStage = nil groupStagePosition = nil @@ -234,6 +275,11 @@ class TeamRegistration: ModelObject, Storable { tournamentObject()?.unrankValue(for: malePlayer) ?? 100_000 } + func groupStageObject() -> GroupStage? { + guard let groupStage else { return nil } + return Store.main.findById(groupStage) + } + func tournamentObject() -> Tournament? { Store.main.findById(tournament) } diff --git a/PadelClub/Data/TeamScore.swift b/PadelClub/Data/TeamScore.swift index 03c120b..5d30135 100644 --- a/PadelClub/Data/TeamScore.swift +++ b/PadelClub/Data/TeamScore.swift @@ -30,6 +30,9 @@ class TeamScore: ModelObject, Storable { self.luckyLoser = luckyLoser } + func matchObject() -> Match? { + Store.main.findById(match) + } var team: TeamRegistration? { guard let teamRegistration else { diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 8f1cad9..22a320c 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -46,10 +46,7 @@ class Tournament : ModelObject, Storable { @ObservationIgnored var navigationPath: [Screen] = [] - - @ObservationIgnored - var undoManager: Int = 0 - + internal init(event: String? = nil, creator: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = true, groupStageFormat: Int? = nil, roundFormat: Int? = nil, loserRoundFormat: Int? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType? = nil, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, groupStageCourtCount: Int? = nil, seedCount: Int = 8, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil, maleUnrankedValue: Int? = nil, femaleUnrankedValue: Int? = nil) { self.event = event self.creator = creator @@ -131,6 +128,115 @@ class Tournament : ModelObject, Storable { return .initial } + + func seeds() -> [TeamRegistration] { + let seeds = max(teamCount - groupStageCount * teamsPerGroupStage, 0) + return Array(selectedSortedTeams().prefix(seeds)) + } + + func availableSeeds() -> [TeamRegistration] { + return seeds().filter { $0.isSeedable() } + } + + func lastSeedRound() -> Int? { + if let last = seeds().filter({ $0.bracketPosition != nil }).last { + return RoundRule.roundIndex(fromMatchIndex: last.bracketPosition! / 2) + } else { + return nil + } + } + + func getRound(atRoundIndex roundIndex: Int) -> Round? { + Store.main.filter(isIncluded: { $0.tournament == id && $0.index == roundIndex }).first + } + + func availableSeedSpot(inRoundIndex roundIndex: Int) -> [Match] { + getRound(atRoundIndex: roundIndex)?.matches.filter { $0.teams().count == 0 } ?? [] + } + + func availableSeedOpponentSpot(inRoundIndex roundIndex: Int) -> [Match] { + getRound(atRoundIndex: roundIndex)?.matches.filter { $0.teams().count == 1 } ?? [] + } + + func availableSeedGroups() -> [SeedInterval] { + let seeds = seeds() + var availableSeedGroup = Set() + for (index, seed) in seeds.enumerated() { + if seed.isSeedable(), let seedGroup = seedGroup(for: index) { + availableSeedGroup.insert(seedGroup) + } + } + return availableSeedGroup.sorted(by: <) + } + + func seedGroup(for alreadySetupSeeds: Int) -> SeedInterval? { + switch alreadySetupSeeds { + case 0...1: + return SeedInterval(first: 1, last: 2) + case 2...3: + return SeedInterval(first: 3, last: 4) + case 4...7: + return SeedInterval(first: 5, last: 8) + case 8...15: +// if 16 - 9 > availableSeeds().count { +// switch alreadySetupSeeds { +// case 8...15: +// return SeedInterval(first: 5, last: 8) +// case 8...15: +// return SeedInterval(first: 5, last: 8) +// } + return SeedInterval(first: 9, last: 16) + case 16...23: + return SeedInterval(first: 17, last: 24) + case 24...31: + return SeedInterval(first: 25, last: 32) + default: + return nil + } + } + + func availableSeedGroup() -> SeedInterval? { + let seeds = seeds() + if let firstIndex = seeds.firstIndex(where: { $0.isSeedable() }) { + guard let seedGroup = seedGroup(for: firstIndex) else { return nil } + return seedGroup + } + return nil + } + + func randomSeed(fromSeedGroup seedGroup: SeedInterval) -> TeamRegistration? { + let availableSeeds = seeds(inSeedGroup: seedGroup) + return availableSeeds.randomElement() + } + + func seeds(inSeedGroup seedGroup: SeedInterval) -> [TeamRegistration] { + let availableSeedInSeedGroup = (seedGroup.last - seedGroup.first) + 1 + let availableSeeds = seeds().dropFirst(seedGroup.first - 1).prefix(availableSeedInSeedGroup).filter({ $0.isSeedable() }) + return availableSeeds + } + + func setSeeds(inRoundIndex roundIndex: Int, inSeedGroup seedGroup: SeedInterval) { + let availableSeedSpot = availableSeedSpot(inRoundIndex: roundIndex) + let availableSeedOpponentSpot = availableSeedOpponentSpot(inRoundIndex: roundIndex) + let availableSeeds = seeds(inSeedGroup: seedGroup) + + if availableSeeds.count <= availableSeedSpot.count { + let spots = availableSeedSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], upperBranch: nil, opposingSeeding: false) + } + } else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count == self.availableSeeds().count) { + + let spots = availableSeedOpponentSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], upperBranch: nil, opposingSeeding: true) + } + } else if let chunk = seedGroup.chunk() { + setSeeds(inRoundIndex: roundIndex, inSeedGroup: chunk) + } + } + + func inscriptionClosed() -> Bool { closedRegistrationDate != nil } @@ -260,11 +366,6 @@ class Tournament : ModelObject, Storable { var clubName: String? { nil } - - //todo - func roundCount() -> Int { - 4 - } //todo func significantPlayerCount() -> Int { @@ -590,7 +691,7 @@ class Tournament : ModelObject, Storable { var matchFormat: MatchFormat { get { - MatchFormat(rawValue: roundFormat ?? 0) ?? .defaultFormatForMatchType(.bracket) + MatchFormat(rawValue: roundFormat) ?? .defaultFormatForMatchType(.bracket) } set { roundFormat = newValue.rawValue @@ -599,7 +700,7 @@ class Tournament : ModelObject, Storable { var groupStageMatchFormat: MatchFormat { get { - MatchFormat(rawValue: groupStageFormat ?? 0) ?? .defaultFormatForMatchType(.groupStage) + MatchFormat(rawValue: groupStageFormat) ?? .defaultFormatForMatchType(.groupStage) } set { groupStageFormat = newValue.rawValue @@ -608,7 +709,7 @@ class Tournament : ModelObject, Storable { var loserBracketMatchFormat: MatchFormat { get { - MatchFormat(rawValue: loserRoundFormat ?? 0) ?? .defaultFormatForMatchType(.loserBracket) + MatchFormat(rawValue: loserRoundFormat) ?? .defaultFormatForMatchType(.loserBracket) } set { loserRoundFormat = newValue.rawValue @@ -662,8 +763,7 @@ class Tournament : ModelObject, Storable { } func loserBracketSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { - let idx = roundCount() - roundIndex - let format = tournamentLevel.federalFormatForLoserBracketRound(idx) + let format = tournamentLevel.federalFormatForLoserBracketRound(roundIndex) if loserBracketMatchFormat.rank > format.rank { return format } else { @@ -681,8 +781,7 @@ class Tournament : ModelObject, Storable { } func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { - let idx = roundCount() - roundIndex - let format = tournamentLevel.federalFormatForBracketRound(idx) + let format = tournamentLevel.federalFormatForBracketRound(roundIndex) if matchFormat.rank > format.rank { return format } else { diff --git a/PadelClub/Manager/PadelRule.swift b/PadelClub/Manager/PadelRule.swift index d244c41..ed3bde8 100644 --- a/PadelClub/Manager/PadelRule.swift +++ b/PadelClub/Manager/PadelRule.swift @@ -967,6 +967,11 @@ enum MatchFormat: Int, Hashable, Codable, CaseIterable { case twoSetsOfFourGamesDecisivePoint case nineGamesDecisivePoint + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + var weight: Int { switch self { case .twoSets, .twoSetsDecisivePoint: @@ -1402,10 +1407,22 @@ enum RoundRule { Int(log2(Double(teamsInFirstRound(forTeams: teams)))) } + static func matchIndex(fromRoundIndex roundIndex: Int) -> Int { + guard roundIndex >= 0 else { + return -1 // Invalid round index + } + + return (1 << roundIndex) - 1 + } + static func roundIndex(fromMatchIndex matchIndex: Int) -> Int { Int(log2(Double(matchIndex + 1))) } + static func numberOfMatches(forRoundIndex roundIndex: Int) -> Int { + Int(pow(2.0, Double(roundIndex))) + } + static func matchIndexWithinRound(fromMatchIndex matchIndex: Int) -> Int { let roundIndex = roundIndex(fromMatchIndex: matchIndex) let matchIndexWithinRound = matchIndex - (Int(pow(2.0, Double(roundIndex))) - 1) diff --git a/PadelClub/ViewModel/TournamentSeedEditing.swift b/PadelClub/ViewModel/TournamentSeedEditing.swift new file mode 100644 index 0000000..4519355 --- /dev/null +++ b/PadelClub/ViewModel/TournamentSeedEditing.swift @@ -0,0 +1,29 @@ +// +// tournamentSeedEditing.swift +// PadelClub +// +// Created by Razmig Sarkissian on 31/03/2024. +// + +import Foundation +import SwiftUI + +// Create an environment key +private struct TournamentSeedEditing: EnvironmentKey { + static let defaultValue: Bool = false +} + +// ## Introduce new value to EnvironmentValues +extension EnvironmentValues { + var isEditingTournamentSeed: Bool { + get { self[TournamentSeedEditing.self] } + set { self[TournamentSeedEditing.self] = newValue } + } +} + +// Add a dedicated modifier (Optional) +extension View { + func editTournamentSeed(_ value: Bool) -> some View { + environment(\.isEditingTournamentSeed, value) + } +} diff --git a/PadelClub/Views/GroupStage/GroupStageView.swift b/PadelClub/Views/GroupStage/GroupStageView.swift index 7653dfa..65dc19d 100644 --- a/PadelClub/Views/GroupStage/GroupStageView.swift +++ b/PadelClub/Views/GroupStage/GroupStageView.swift @@ -196,7 +196,7 @@ struct GroupStageView: View { if groupStage.matches.isEmpty == false { Section { ForEach(groupStage.matches) { match in - MatchRowView(match: match, setupSeedContext: false, matchViewStyle: .sectionedStandardStyle) + MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle) } } header: { Text("Matchs de la " + groupStage.groupStageTitle()) diff --git a/PadelClub/Views/Match/MatchRowView.swift b/PadelClub/Views/Match/MatchRowView.swift index e1f69c5..32c244c 100644 --- a/PadelClub/Views/Match/MatchRowView.swift +++ b/PadelClub/Views/Match/MatchRowView.swift @@ -9,12 +9,12 @@ import SwiftUI struct MatchRowView: View { var match: Match - let setupSeedContext: Bool let matchViewStyle: MatchViewStyle + @Environment(\.isEditingTournamentSeed) private var isEditingTournamentSeed @ViewBuilder var body: some View { - if setupSeedContext { + if isEditingTournamentSeed && match.isGroupStage() == false { MatchSetupView(match: match) } else { NavigationLink { @@ -29,5 +29,5 @@ struct MatchRowView: View { #Preview { - MatchRowView(match: Match.mock(), setupSeedContext: false, matchViewStyle: .standardStyle) + MatchRowView(match: Match.mock(), matchViewStyle: .standardStyle) } diff --git a/PadelClub/Views/Match/MatchSetupView.swift b/PadelClub/Views/Match/MatchSetupView.swift index bcffe9f..6391370 100644 --- a/PadelClub/Views/Match/MatchSetupView.swift +++ b/PadelClub/Views/Match/MatchSetupView.swift @@ -8,28 +8,62 @@ import SwiftUI struct MatchSetupView: View { + @EnvironmentObject var dataStore: DataStore var match: Match + @State private var seedGroup: SeedInterval? + @ViewBuilder var body: some View { - HStack { - VStack(alignment: .leading) { - _teamView(match.team(.one), index: 0) - _teamView(match.team(.two), index: 1) - } - } + _teamView(match.team(.one), teamPosition: 0) + _teamView(match.team(.two), teamPosition: 1) } @ViewBuilder - func _teamView(_ team: TeamRegistration?, index: Int) -> some View { + func _teamView(_ team: TeamRegistration?, teamPosition: Int) -> some View { if let team { - TeamDetailView(team: team) + TeamRowView(team: team, teamPosition: teamPosition) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .cancel) { + team.bracketPosition = nil + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + } label: { + Label("retirer", systemImage: "xmark") + } + } } else { - TeamPickerView(match: match, index: match.index*2 + 1 + index) - .disabled(match.groupStage != nil) + HStack { + TeamPickerView(teamPicked: { team in + print(team.pasteData()) + team.setSeedPosition(inSpot: match, upperBranch: teamPosition, opposingSeeding: false) + try? dataStore.matches.addOrUpdate(instance: match) + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + }) + if let tournament = match.currentTournament() { + Menu { + ForEach(tournament.availableSeedGroups(), id: \.self) { seedGroup in + Button { + if let randomTeam = tournament.randomSeed(fromSeedGroup: seedGroup) { + randomTeam.setSeedPosition(inSpot: match, upperBranch: teamPosition, opposingSeeding: false) + try? dataStore.matches.addOrUpdate(instance: match) + try? dataStore.teamRegistrations.addOrUpdate(instance: randomTeam) + } + } label: { + Label(seedGroup.localizedLabel(), systemImage: "dice") + } + } + } label: { + Text("Tirage").tag(nil as SeedInterval?) + } + } + } + .fixedSize(horizontal: false, vertical: true) + .buttonBorderShape(.capsule) + .buttonStyle(.borderedProminent) } } } #Preview { MatchSetupView(match: Match.mock()) + .environmentObject(DataStore.shared) } diff --git a/PadelClub/Views/Round/RoundSettingsView.swift b/PadelClub/Views/Round/RoundSettingsView.swift index 3b40592..7f9362f 100644 --- a/PadelClub/Views/Round/RoundSettingsView.swift +++ b/PadelClub/Views/Round/RoundSettingsView.swift @@ -8,15 +8,124 @@ import SwiftUI struct RoundSettingsView: View { + @EnvironmentObject var dataStore: DataStore @Environment(Tournament.self) var tournament: Tournament + @Binding var isEditingTournamentSeed: Bool + @State private var roundIndex: Int? + + var round: Round? { + guard let roundIndex else { return nil } + return tournament.rounds()[roundIndex] + } var body: some View { List { + Toggle("Éditer les têtes de série", isOn: $isEditingTournamentSeed) + + Section { + RowButtonView(title: "Retirer toutes les têtes de séries") { + tournament.unsortedTeams().forEach({ $0.bracketPosition = nil }) + } + } + + Section { + if let lastRound = tournament.rounds().first { // first is final, last round + RowButtonView(title: "Supprimer " + lastRound.roundTitle()) { + try? dataStore.rounds.delete(instance: lastRound) + } + } + } + + Section { + let roundIndex = tournament.rounds().count + RowButtonView(title: "Ajouter " + RoundRule.roundName(fromRoundIndex: roundIndex)) { + let round = Round(tournament: tournament.id, index: roundIndex, matchFormat: tournament.matchFormat) + let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex) + let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex) + let matches = (0.. Bool { + return lhs.first < rhs.first + } + + func chunk() -> SeedInterval? { + if last - (last - first) / 2 > first { + return SeedInterval(first: first, last: last - (last - first) / 2) + } else { + return nil + } + } +} + +extension SeedInterval { + func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if last - first < 2 { + return "#\(first) / #\(last)" + } else { + return "#\(first) à #\(last)" + } + } } diff --git a/PadelClub/Views/Round/RoundView.swift b/PadelClub/Views/Round/RoundView.swift index 0ddfe48..d018085 100644 --- a/PadelClub/Views/Round/RoundView.swift +++ b/PadelClub/Views/Round/RoundView.swift @@ -14,9 +14,9 @@ struct RoundView: View { List { ForEach(round.matches) { match in Section { - MatchRowView(match: match, setupSeedContext: false, matchViewStyle: .sectionedStandardStyle) + MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle) } header: { - Text(match.matchTitle()) + Text(round.roundTitle(.wide) + " " + match.matchTitle(.short)) } } } diff --git a/PadelClub/Views/Round/RoundsView.swift b/PadelClub/Views/Round/RoundsView.swift index eeb1609..7d028b3 100644 --- a/PadelClub/Views/Round/RoundsView.swift +++ b/PadelClub/Views/Round/RoundsView.swift @@ -10,10 +10,14 @@ import SwiftUI struct RoundsView: View { var tournament: Tournament @State private var selectedRound: Round? + @State private var isEditingTournamentSeed = false init(tournament: Tournament) { self.tournament = tournament _selectedRound = State(wrappedValue: tournament.getActiveRound()) + if tournament.availableSeeds().isEmpty == false { + _isEditingTournamentSeed = State(wrappedValue: true) + } } var body: some View { @@ -21,11 +25,12 @@ struct RoundsView: View { GenericDestinationPickerView(selectedDestination: $selectedRound, destinations: tournament.rounds(), nilDestinationIsValid: true) switch selectedRound { case .none: - RoundSettingsView() + RoundSettingsView(isEditingTournamentSeed: $isEditingTournamentSeed) .navigationTitle("Réglages") case .some(let selectedRound): RoundView(round: selectedRound) .navigationTitle(selectedRound.roundTitle()) + .editTournamentSeed(isEditingTournamentSeed) } } .navigationBarTitleDisplayMode(.inline) diff --git a/PadelClub/Views/Team/TeamPickerView.swift b/PadelClub/Views/Team/TeamPickerView.swift index 712c37d..d33d429 100644 --- a/PadelClub/Views/Team/TeamPickerView.swift +++ b/PadelClub/Views/Team/TeamPickerView.swift @@ -8,15 +8,67 @@ import SwiftUI struct TeamPickerView: View { - var match: Match - var index: Int + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament: Tournament + @Environment(\.dismiss) private var dismiss + @State private var presentTeamPickerView: Bool = false + @State private var searchField: String = "" + let teamPicked: ((TeamRegistration) -> (Void)) var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button("Choisir") { + presentTeamPickerView = true + } + .sheet(isPresented: $presentTeamPickerView) { + NavigationStack { + List { + let teams = tournament.sortedTeams() + Section { + _teamListView(teams.filter({ $0.available() }).sorted(by: \.weight).reversed()) + } header: { + Text("Disponible") + } + Section { + _teamListView(teams.filter({ $0.inGroupStage() }).sorted(by: \.groupStagePosition!).reversed()) + } header: { + Text("Déjà placée en poule") + } + Section { + _teamListView(teams.filter({ $0.inRound() }).sorted(by: \.bracketPosition!).reversed()) + } header: { + Text("Déjà placée dans le tableau") + } + + } + .searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always)) + .keyboardType(.alphabet) + .autocorrectionDisabled() + .navigationTitle("Choisir une équipe") + .toolbarBackground(.visible, for: .navigationBar) + .navigationBarTitleDisplayMode(.inline) + } + } + } + + private func _teamListView(_ teams: [TeamRegistration]) -> some View { + ForEach(teams) { team in + if searchField.isEmpty || team.contains(searchField) { + Button { + teamPicked(team) + presentTeamPickerView = false + } label: { + TeamRowView(team: team) + } + .buttonStyle(.plain) + } + } } } #Preview { - TeamPickerView(match: Match.mock(), index: 0) + TeamPickerView(teamPicked: { team in + }) + .environment(Tournament.mock()) + .environmentObject(DataStore.shared) } diff --git a/PadelClub/Views/Team/TeamRowView.swift b/PadelClub/Views/Team/TeamRowView.swift index 617aa1f..e24a93c 100644 --- a/PadelClub/Views/Team/TeamRowView.swift +++ b/PadelClub/Views/Team/TeamRowView.swift @@ -8,11 +8,29 @@ import SwiftUI struct TeamRowView: View { - @EnvironmentObject var dataStore: DataStore var team: TeamRegistration - + var teamPosition: Int? = nil + var body: some View { - TeamDetailView(team: team) + LabeledContent { + VStack(alignment: .trailing, spacing: 0) { + if teamPosition == 0 || teamPosition == nil { + Text(team.weight.formatted()) + .font(.caption) + } + if let teams = team.tournamentObject()?.selectedSortedTeams(), let index = team.index(in: teams) { + Text("#" + (index + 1).formatted()) + .font(.title) + } + if teamPosition == 1 { + Text(team.weight.formatted()) + .font(.caption) + + } + } + } label: { + Text(team.teamLabel(.short)) + } } } diff --git a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift index 2677b8c..663b9e8 100644 --- a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift @@ -26,7 +26,7 @@ struct InscriptionInfoView: View { Section { DisclosureGroup { ForEach(waitingListInBracket) { team in - TeamRowView(team: team) + TeamDetailView(team: team) } } label: { LabeledContent { @@ -39,7 +39,7 @@ struct InscriptionInfoView: View { DisclosureGroup { ForEach(waitingListInGroupStage) { team in - TeamRowView(team: team) + TeamDetailView(team: team) } } label: { LabeledContent { @@ -126,7 +126,7 @@ struct InscriptionInfoView: View { Section { DisclosureGroup { ForEach(playersMissing) { - TeamRowView(team: $0) + TeamDetailView(team: $0) } } label: { LabeledContent { diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index d8acad0..8eacdd6 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -232,7 +232,7 @@ struct InscriptionManagerView: View { ForEach(teams) { team in let teamIndex = team.index(in: unfilteredTeams) Section { - TeamRowView(team: team) + TeamDetailView(team: team) } header: { _teamHeaderView(team, teamIndex: teamIndex) } footer: {