fix scheduler and add court pickup

paca_championship
Raz 1 year ago
parent b3144be82d
commit c025559b5e
  1. 8
      PadelClub.xcodeproj/project.pbxproj
  2. 107
      PadelClub/Data/MatchScheduler.swift
  3. 4
      PadelClub/Data/Tournament.swift
  4. 38
      PadelClub/Views/Planning/Components/MultiCourtPickerView.swift
  5. 54
      PadelClub/Views/Planning/PlanningSettingsView.swift

@ -81,6 +81,9 @@
FF1162872BD004AD000C4809 /* EditingTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1162862BD004AD000C4809 /* EditingTeamView.swift */; };
FF11628A2BD05247000C4809 /* DateUpdateManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */; };
FF11628C2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF11628B2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift */; };
FF17CA492CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; };
FF17CA4A2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; };
FF17CA4B2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; };
FF1CBC1B2BB53D1F0036DAAB /* FederalTournament.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */; };
FF1CBC1D2BB53DC10036DAAB /* Calendar+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */; };
FF1CBC1F2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */; };
@ -977,6 +980,7 @@
FF1162862BD004AD000C4809 /* EditingTeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditingTeamView.swift; sourceTree = "<group>"; };
FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateUpdateManagerView.swift; sourceTree = "<group>"; };
FF11628B2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundStepScheduleEditorView.swift; sourceTree = "<group>"; };
FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiCourtPickerView.swift; sourceTree = "<group>"; };
FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournament.swift; sourceTree = "<group>"; };
FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Extensions.swift"; sourceTree = "<group>"; };
FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournamentSearchScope.swift; sourceTree = "<group>"; };
@ -1514,6 +1518,7 @@
isa = PBXGroup;
children = (
FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */,
FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -2388,6 +2393,7 @@
FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */,
FFBF41842BF75ED7001B24CB /* EventTournamentsView.swift in Sources */,
FF1DC55B2BAB80C400FD8220 /* DisplayContext.swift in Sources */,
FF17CA4A2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */,
FF9268072BCE94D90080F940 /* TournamentCallView.swift in Sources */,
FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */,
FF967CFC2BAEE52E00A9A3BD /* GroupStagesView.swift in Sources */,
@ -2657,6 +2663,7 @@
FF4CBFE92C996C0600151637 /* CloudConvert.swift in Sources */,
FF4CBFEA2C996C0600151637 /* EventTournamentsView.swift in Sources */,
FF4CBFEB2C996C0600151637 /* DisplayContext.swift in Sources */,
FF17CA4B2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */,
FF4CBFEC2C996C0600151637 /* TournamentCallView.swift in Sources */,
FF4CBFED2C996C0600151637 /* LoserRoundsView.swift in Sources */,
FF4CBFEE2C996C0600151637 /* GroupStagesView.swift in Sources */,
@ -2905,6 +2912,7 @@
FF70FB682C90584900129CC2 /* CloudConvert.swift in Sources */,
FF70FB692C90584900129CC2 /* EventTournamentsView.swift in Sources */,
FF70FB6A2C90584900129CC2 /* DisplayContext.swift in Sources */,
FF17CA492CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */,
FF70FB6B2C90584900129CC2 /* TournamentCallView.swift in Sources */,
FF70FB6C2C90584900129CC2 /* LoserRoundsView.swift in Sources */,
FF70FB6D2C90584900129CC2 /* GroupStagesView.swift in Sources */,

@ -31,6 +31,7 @@ final class MatchScheduler : ModelObject, Storable {
var groupStageChunkCount: Int?
var overrideCourtsUnavailability: Bool = false
var shouldTryToFillUpCourtsAvailable: Bool = false
var courtsAvailable: Set<Int> = Set<Int>()
init(tournament: String,
timeDifferenceLimit: Int = 5,
@ -42,7 +43,7 @@ final class MatchScheduler : ModelObject, Storable {
rotationDifferenceIsImportant: Bool = false,
shouldHandleUpperRoundSlice: Bool = true,
shouldEndRoundBeforeStartingNext: Bool = true,
groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) {
groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false, courtsAvailable: Set<Int> = Set<Int>()) {
self.tournament = tournament
self.timeDifferenceLimit = timeDifferenceLimit
self.loserBracketRotationDifference = loserBracketRotationDifference
@ -56,6 +57,7 @@ final class MatchScheduler : ModelObject, Storable {
self.groupStageChunkCount = groupStageChunkCount
self.overrideCourtsUnavailability = overrideCourtsUnavailability
self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable
self.courtsAvailable = courtsAvailable
}
enum CodingKeys: String, CodingKey {
@ -73,6 +75,7 @@ final class MatchScheduler : ModelObject, Storable {
case _groupStageChunkCount = "groupStageChunkCount"
case _overrideCourtsUnavailability = "overrideCourtsUnavailability"
case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable"
case _courtsAvailable = "courtsAvailable"
}
var courtsUnavailability: [DateInterval]? {
@ -99,7 +102,6 @@ final class MatchScheduler : ModelObject, Storable {
if let specificGroupStage {
groupStages = [specificGroupStage]
}
let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount
let matches = groupStages.flatMap { $0._matches() }
matches.forEach({
@ -127,7 +129,7 @@ final class MatchScheduler : ModelObject, Storable {
lastDate = time
}
let groups = groupStages.filter({ $0.startDate == time })
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate)
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
dispatch.timedMatches.forEach { matchSchedule in
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
@ -151,7 +153,7 @@ final class MatchScheduler : ModelObject, Storable {
Logger.error(error)
}
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate)
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
dispatch.timedMatches.forEach { matchSchedule in
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
@ -174,7 +176,7 @@ final class MatchScheduler : ModelObject, Storable {
return lastDate
}
func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher {
func groupStageDispatcher(groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher {
let _groupStages = groupStages
@ -214,7 +216,7 @@ final class MatchScheduler : ModelObject, Storable {
print("Match \(match.roundAndMatchTitle()) has teams already scheduled in rotation \(rotationIndex)")
}
return teamsAvailable
}).prefix(numberOfCourtsAvailablePerRotation))
}).prefix(courtsAvailable.count))
if rotationIndex > 0 {
rotationMatches = rotationMatches.sorted(by: {
@ -226,7 +228,7 @@ final class MatchScheduler : ModelObject, Storable {
})
}
(0..<numberOfCourtsAvailablePerRotation).forEach { courtIndex in
courtsAvailable.forEach { courtIndex in
print("Checking availability for court \(courtIndex) in rotation \(rotationIndex)")
if let first = rotationMatches.first(where: { match in
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
@ -427,7 +429,7 @@ final class MatchScheduler : ModelObject, Storable {
)
}
func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher {
func roundDispatcher(flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher {
var slots = [TimeMatch]()
var _startDate: Date?
var rotationIndex = 0
@ -436,7 +438,7 @@ final class MatchScheduler : ModelObject, Storable {
var issueFound: Bool = false
// Log start of the function
print("Starting roundDispatcher with \(availableMatchs.count) matches and \(numberOfCourtsAvailablePerRotation) courts available")
print("Starting roundDispatcher with \(availableMatchs.count) matches and \(courtsAvailable) courts available")
flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in
if _startDate == nil {
@ -455,8 +457,7 @@ final class MatchScheduler : ModelObject, Storable {
}
var freeCourtPerRotation = [Int: [Int]]()
let availableCourt = numberOfCourtsAvailablePerRotation
var courts = initialCourts ?? (0..<availableCourt).map { $0 }
var courts = initialCourts ?? Array(courtsAvailable)
var shouldStartAtDispatcherDate = rotationIndex > 0
while !availableMatchs.isEmpty && !issueFound && rotationIndex < 100 {
@ -468,7 +469,7 @@ final class MatchScheduler : ModelObject, Storable {
rotationStartDate = dispatcherStartDate
shouldStartAtDispatcherDate = false
} else {
courts = rotationIndex == 0 ? courts : (0..<availableCourt).map { $0 }
courts = rotationIndex == 0 ? courts : Array(courtsAvailable)
}
courts.sort()
@ -520,16 +521,22 @@ final class MatchScheduler : ModelObject, Storable {
let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.count == numberOfCourtsAvailablePerRotation {
if Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable))).isEmpty {
print("Issue: All courts unavailable in this rotation")
issueFound = true
if let courtsUnavailability {
let computedStartDateAndCourts = getFirstFreeCourt(startDate: rotationStartDate, duration: duration, courts: courts, courtsUnavailability: courtsUnavailability)
rotationStartDate = computedStartDateAndCourts.earliestFreeDate
courts = computedStartDateAndCourts.availableCourts
} else {
issueFound = true
}
} else {
courts = Array(Set(courts).subtracting(Set(courtsUnavailable)))
courts = Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable)))
}
}
// Dispatch courts and schedule matches
dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
dispatchCourts(courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
rotationIndex += 1
}
@ -551,7 +558,7 @@ final class MatchScheduler : ModelObject, Storable {
return MatchDispatcher(timedMatches: slots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound)
}
func dispatchCourts(availableCourts: Int, courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) {
func dispatchCourts(courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) {
var matchPerRound = [String: Int]()
var minimumTargetedEndDate = rotationStartDate
@ -567,7 +574,7 @@ final class MatchScheduler : ModelObject, Storable {
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.contains(courtPosition) {
if courtsUnavailable.contains(courtIndex) {
print("Returning false: Court \(courtIndex) unavailable due to schedule conflicts during \(rotationStartDate).")
return false
}
@ -591,13 +598,16 @@ final class MatchScheduler : ModelObject, Storable {
let indexInRound = match.indexInRound()
if roundObject.parent == nil && roundObject.index > 0 && indexInRound == 0, let nextMatch = match.next() {
if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) {
print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).")
return true
} else {
print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).")
return false
if shouldTryToFillUpCourtsAvailable == false {
if roundObject.parent == nil && roundObject.index > 1 && indexInRound == 0, let nextMatch = match.next() {
if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) {
print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).")
return true
} else {
print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).")
return false
}
}
}
@ -626,7 +636,7 @@ final class MatchScheduler : ModelObject, Storable {
}
if freeCourtPerRotation[rotationIndex]?.count == availableCourts {
if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count {
print("All courts in rotation \(rotationIndex) are free")
}
}
@ -713,7 +723,7 @@ final class MatchScheduler : ModelObject, Storable {
print("initial available courts at beginning: \(courts ?? [])")
let roundDispatch = self.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts)
let roundDispatch = self.roundDispatcher(flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts)
roundDispatch.timedMatches.forEach { matchSchedule in
if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) {
@ -750,7 +760,50 @@ final class MatchScheduler : ModelObject, Storable {
})
}
func getFirstFreeCourt(startDate: Date, duration: Int, courts: [Int], courtsUnavailability: [DateInterval]) -> (earliestFreeDate: Date, availableCourts: [Int]) {
var earliestEndDate: Date?
var availableCourtsAtEarliest: [Int] = []
// Iterate through each court and find the earliest time it becomes free
for courtIndex in courts {
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
var isAvailable = true
for interval in unavailabilityForCourt {
if interval.startDate <= startDate && interval.endDate > startDate {
isAvailable = false
if let currentEarliest = earliestEndDate {
earliestEndDate = min(currentEarliest, interval.endDate)
} else {
earliestEndDate = interval.endDate
}
}
}
// If the court is available at the start date, add it to the list of available courts
if isAvailable {
availableCourtsAtEarliest.append(courtIndex)
}
}
// If there are no unavailable courts, return the original start date and all courts
if let earliestEndDate = earliestEndDate {
// Find which courts will be available at the earliest free date
let courtsAvailableAtEarliest = courts.filter { courtIndex in
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
return unavailabilityForCourt.allSatisfy { $0.endDate <= earliestEndDate }
}
return (earliestFreeDate: earliestEndDate, availableCourts: courtsAvailableAtEarliest)
} else {
// If no courts were unavailable, all courts are available at the start date
return (earliestFreeDate: startDate.addingTimeInterval(Double(duration) * 60), availableCourts: courts)
}
}
func updateSchedule(tournament: Tournament) -> Bool {
if tournament.courtCount < courtsAvailable.count {
courtsAvailable = Set(tournament.courtsAvailable())
}
var lastDate = tournament.startDate
if tournament.groupStageCount > 0 {
lastDate = updateGroupStageSchedule(tournament: tournament)

@ -2045,6 +2045,10 @@ defer {
return self._matchSchedulers().first
}
func courtsAvailable() -> [Int] {
(0..<courtCount).map { $0 }
}
func currentMonthData() -> MonthData? {
guard let rankSourceDate else { return nil }
let dateString = URL.importDateFormatter.string(from: rankSourceDate)

@ -0,0 +1,38 @@
//
// MultiCourtPickerView.swift
// PadelClub
//
// Created by razmig on 11/10/2024.
//
import SwiftUI
struct MultiCourtPickerView: View {
@Bindable var matchScheduler: MatchScheduler
@Environment(Tournament.self) var tournament: Tournament
var body: some View {
List {
ForEach(tournament.courtsAvailable(), id: \.self) { courtIndex in
LabeledContent {
Button {
if matchScheduler.courtsAvailable.contains(courtIndex) {
matchScheduler.courtsAvailable.remove(courtIndex)
} else {
matchScheduler.courtsAvailable.insert(courtIndex)
}
} label: {
if matchScheduler.courtsAvailable.contains(courtIndex) {
Image(systemName: "checkmark.circle.fill")
}
}
} label: {
Text(tournament.courtName(atIndex: courtIndex))
}
}
}
.navigationTitle("Terrains disponibles")
.toolbarBackground(.visible, for: .navigationBar)
.environment(\.editMode, Binding.constant(EditMode.active))
}
}

@ -39,7 +39,7 @@ struct PlanningSettingsView: View {
_groupStageChunkCount = State(wrappedValue: tournament.getGroupStageChunkValue())
}
} else {
self.matchScheduler = MatchScheduler(tournament: tournament.id)
self.matchScheduler = MatchScheduler(tournament: tournament.id, courtsAvailable: Set(tournament.courtsAvailable()))
self._groupStageChunkCount = State(wrappedValue: tournament.getGroupStageChunkValue())
}
}
@ -68,7 +68,26 @@ struct PlanningSettingsView: View {
CourtAvailabilitySettingsView(event: event)
.environment(tournament)
} label: {
Text("Indisponibilités des terrains")
LabeledContent {
Text(event.courtsUnavailability.count.formatted())
} label: {
Text("Créneaux d'indisponibilités")
}
}
}
NavigationLink {
MultiCourtPickerView(matchScheduler: matchScheduler)
.environment(tournament)
} label: {
LabeledContent {
Text(matchScheduler.courtsAvailable.count.formatted() + "/" + tournament.courtCount.formatted())
} label: {
Text("Sélection des terrains")
if matchScheduler.courtsAvailable.count > tournament.courtCount {
Text("Attention !")
.tint(.red)
}
}
}
} footer: {
@ -105,12 +124,23 @@ struct PlanningSettingsView: View {
.foregroundStyle(.logoRed)
}
let event = tournament.eventObject()
Section {
NavigationLink {
_optionsView()
} label: {
Text("Voir plus d'options intelligentes")
}
if let event, event.tournaments.count > 1 {
Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) {
Text("Ne pas tenir compte des autres tournois")
}
}
} footer: {
if let event, event.tournaments.count > 1 {
Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi de cet événement soit toujours considéré comme libre.")
}
}
let allMatches = tournament.allMatches()
@ -271,28 +301,20 @@ struct PlanningSettingsView: View {
}
}
// Section {
// Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) {
// Text("Remplir au maximum les terrains d'une rotation")
// }
// } footer: {
// Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.")
// }
//
Section {
Toggle(isOn: $matchScheduler.shouldHandleUpperRoundSlice) {
Text("Équilibrer les matchs d'une manche")
Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) {
Text("Remplir au maximum les terrains d'une rotation")
}
} footer: {
Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.")
Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.")
}
Section {
Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) {
Text("Ne pas tenir compte des autres tournois")
Toggle(isOn: $matchScheduler.shouldHandleUpperRoundSlice) {
Text("Équilibrer les matchs d'une manche")
}
} footer: {
Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi est toujours considéré comme libre.")
Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.")
}
Section {

Loading…
Cancel
Save